Skip to content

Commit 1c11f61

Browse files
authored
feat: --id flag for cn serve (#7884)
* feat: --id flag for cn serve * fix: lint * fix: address feedback
1 parent b12499c commit 1c11f61

File tree

9 files changed

+667
-39
lines changed

9 files changed

+667
-39
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Storage Sync Flow for `cn serve`
2+
3+
## Overview
4+
5+
The `--id <storageId>` flag enables the `cn serve` command to periodically persist session state to an external Continue-managed storage bucket. On startup, the CLI exchanges the provided `storageId` for two pre-signed S3 URLs - one for `session.json` and one for `diff.txt` - and then pushes fresh copies of those files every 30 seconds.
6+
7+
This document captures the responsibilities for both the CLI and backend components so we can iterate on the feature together.
8+
9+
## CLI Responsibilities
10+
11+
- **Flag plumbing**: When `cn serve` is invoked with `--id <storageId>`, the CLI treats that value as an opaque identifier.
12+
- **API key auth**: The CLI attaches the user-level Continue API key (same mechanism we already use for other authenticated requests) to backend calls.
13+
- **Presign handshake**:
14+
1. On startup, issue `POST https://api.continue.dev/agents/storage/presigned-url` with JSON payload `{ "storageId": "<storageId>" }`.
15+
2. Expect a response payload containing two pre-signed `PUT` URLs and their target object keys:
16+
```json
17+
{
18+
"session": {
19+
"key": "sessions/<sessionId>/session.json",
20+
"putUrl": "https://<s3-host>/..."
21+
},
22+
"diff": {
23+
"key": "sessions/<sessionId>/diff.txt",
24+
"putUrl": "https://<s3-host>/..."
25+
}
26+
}
27+
```
28+
3. If the call fails, log and continue without remote storage (no retries yet).
29+
- **Periodic uploads**:
30+
- Every 30 seconds (configurable later), serialize the in-memory session to `session.json` and fetch the `/diff` payload to produce `diff.txt`.
31+
- Upload both artifacts using their respective `PUT` URLs. For now we overwrite the same objects each cycle.
32+
- Errors should be logged but non-fatal; the server keeps running.
33+
- If the repo check fails (no git repo or missing `main`), `diff.txt` uploads an empty string and we log the condition once for debugging.
34+
35+
## Backend Responsibilities
36+
37+
- **Endpoint surface**: `POST /agents/storage/presigned-url` accepts a JSON body `{ "storageId": string }`.
38+
- **Authentication**: Leverage the caller's Continue API key (the request arrives with the standard `Authorization: Bearer <apiKey>` header). Apply normal auth/tenant validation so users can only request URLs tied to their account/org.
39+
- **URL issuance**:
40+
- Resolve `storageId` into the desired S3 prefix (e.g., `sessions/<org>/<storageId>/`).
41+
- Generate two short-lived pre-signed `PUT` URLs: one for `session.json`, one for `diff.txt`.
42+
- Return both URLs and their keys in the response payload described above.
43+
- **Expiration**: The initial implementation can hand out URLs with a generous TTL (e.g., 60 minutes). Later we will add CLI-side refresh logic before expiry.
44+
45+
## Open Questions & Future Enhancements
46+
47+
- **URL refresh**: When the presigned URLs expire we currently have no refresh cycle. We'd need either a renewal endpoint or to re-call the existing endpoint on failure.
48+
- **Upload cadence**: The 30-second interval is hard-coded for now. Consider making it configurable in both CLI and backend policies.
49+
- **Error telemetry**: Decide if repeated upload failures should trip analytics or circuit breakers.
50+
- **Diff source**: `diff.txt` currently mirrors the `/diff` endpoint response. Confirm backend expectations for format and size limits.
51+
- **Security**: We might want to sign responses or enforce stricter scope on `storageId` mapping (e.g., require both org + storageId and validate ownership).
52+
53+
---
54+
55+
This document should evolve alongside implementation details; update it whenever the API contract or client behavior changes.

extensions/cli/src/commands/serve.ts

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
import { exec } from "child_process";
2-
import { promisify } from "util";
3-
41
import chalk from "chalk";
52
import type { ChatHistoryItem } from "core/index.js";
63
import express, { Request, Response } from "express";
74

8-
import { getAssistantSlug } from "../auth/workos.js";
5+
import { getAccessToken, getAssistantSlug } from "../auth/workos.js";
96
import { processCommandFlags } from "../flags/flagProcessor.js";
107
import { toolPermissionManager } from "../permissions/permissionManager.js";
118
import {
@@ -19,11 +16,12 @@ import {
1916
ConfigServiceState,
2017
ModelServiceState,
2118
} from "../services/types.js";
22-
import { createSession } from "../session.js";
19+
import { createSession, getSessionPersistenceSnapshot } from "../session.js";
2320
import { constructSystemMessage } from "../systemMessage.js";
2421
import { telemetryService } from "../telemetry/telemetryService.js";
2522
import { gracefulExit } from "../util/exit.js";
2623
import { formatError } from "../util/formatError.js";
24+
import { getGitDiffSnapshot } from "../util/git.js";
2725
import { logger } from "../util/logger.js";
2826
import { readStdinSync } from "../util/stdin.js";
2927

@@ -33,11 +31,11 @@ import {
3331
type ServerState,
3432
} from "./serve.helpers.js";
3533

36-
const execAsync = promisify(exec);
37-
3834
interface ServeOptions extends ExtendedCommandOptions {
3935
timeout?: string;
4036
port?: string;
37+
/** Storage identifier for remote sync */
38+
id?: string;
4139
}
4240

4341
// eslint-disable-next-line max-statements
@@ -86,6 +84,7 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
8684
if (authState.organizationId) {
8785
telemetryService.updateOrganization(authState.organizationId);
8886
}
87+
const accessToken = getAccessToken(authState.authConfig);
8988

9089
// Log configuration information
9190
const organizationId = authState.organizationId || "personal";
@@ -146,6 +145,32 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
146145
pendingPermission: null,
147146
};
148147

148+
const syncSessionHistory = () => {
149+
try {
150+
state.session.history = services.chatHistory.getHistory();
151+
} catch (e) {
152+
logger.debug(
153+
`Failed to sync session history from ChatHistoryService: ${formatError(e)}`,
154+
);
155+
}
156+
};
157+
158+
const storageSyncService = services.storageSync;
159+
let storageSyncActive = await storageSyncService.startFromOptions({
160+
storageOption: options.id,
161+
accessToken,
162+
syncSessionHistory,
163+
getSessionSnapshot: () => getSessionPersistenceSnapshot(state.session),
164+
isActive: () => state.serverRunning,
165+
});
166+
167+
const stopStorageSync = () => {
168+
if (storageSyncActive) {
169+
storageSyncService.stop();
170+
storageSyncActive = false;
171+
}
172+
};
173+
149174
// Record session start
150175
telemetryService.recordSessionStart();
151176
telemetryService.startActiveTime();
@@ -156,14 +181,7 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
156181
// GET /state - Return the current state
157182
app.get("/state", (_req: Request, res: Response) => {
158183
state.lastActivity = Date.now();
159-
// Ensure session history reflects ChatHistoryService state
160-
try {
161-
state.session.history = services.chatHistory.getHistory();
162-
} catch (e) {
163-
logger.debug(
164-
`Failed to sync session history from ChatHistoryService: ${formatError(e)}`,
165-
);
166-
}
184+
syncSessionHistory();
167185
res.json({
168186
session: state.session, // Return session directly instead of converting
169187
isProcessing: state.isProcessing,
@@ -239,34 +257,25 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
239257
state.lastActivity = Date.now();
240258

241259
try {
242-
// First check if we're in a git repository
243-
await execAsync("git rev-parse --git-dir");
260+
const diffResult = await getGitDiffSnapshot();
244261

245-
// Get the diff against main branch
246-
const { stdout } = await execAsync("git diff main");
247-
248-
res.json({
249-
diff: stdout,
250-
});
251-
} catch (error: any) {
252-
// Git diff returns exit code 1 when there are differences, which is normal
253-
if (error.code === 1 && error.stdout) {
254-
res.json({
255-
diff: error.stdout,
256-
});
257-
} else if (error.code === 128) {
258-
// Handle case where we're not in a git repo or main branch doesn't exist
262+
if (!diffResult.repoFound) {
259263
res.status(404).json({
260264
error: "Not in a git repository or main branch doesn't exist",
261265
diff: "",
262266
});
263-
} else {
264-
logger.error(`Git diff error: ${formatError(error)}`);
265-
res.status(500).json({
266-
error: `Failed to get git diff: ${formatError(error)}`,
267-
diff: "",
268-
});
267+
return;
269268
}
269+
270+
res.json({
271+
diff: diffResult.diff,
272+
});
273+
} catch (error) {
274+
logger.error(`Git diff error: ${formatError(error)}`);
275+
res.status(500).json({
276+
error: `Failed to get git diff: ${formatError(error)}`,
277+
diff: "",
278+
});
270279
}
271280
});
272281

@@ -287,6 +296,7 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
287296

288297
// Set server running flag to false to stop processing
289298
state.serverRunning = false;
299+
stopStorageSync();
290300

291301
// Abort any current processing
292302
if (state.currentAbortController) {
@@ -438,6 +448,7 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
438448
),
439449
);
440450
state.serverRunning = false;
451+
stopStorageSync();
441452
server.close(() => {
442453
telemetryService.stopActiveTime();
443454
gracefulExit(0).catch((err) => {
@@ -456,6 +467,7 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
456467
process.on("SIGINT", () => {
457468
console.log(chalk.yellow("\nShutting down server..."));
458469
state.serverRunning = false;
470+
stopStorageSync();
459471
if (inactivityChecker) {
460472
clearInterval(inactivityChecker);
461473
inactivityChecker = null;

extensions/cli/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,10 @@ program
312312
"300",
313313
)
314314
.option("--port <port>", "Port to run the server on (default: 8000)", "8000")
315+
.option(
316+
"--id <storageId>",
317+
"Upload session snapshots to Continue-managed storage using the provided identifier",
318+
)
315319
.action(async (prompt, options) => {
316320
// Telemetry: record command invocation
317321
await posthogService.capture("cliCommand", { command: "serve" });

0 commit comments

Comments
 (0)