-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(agent-core-v2): read and write the v1 session index file #1616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/agent-core-v2": patch | ||
| --- | ||
|
|
||
| Write v1-compatible session index entries when the v2 engine creates or forks sessions, so both engine generations discover each other's sessions on the same data directory. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| /** | ||
| * `sessionIndex` domain (L2) — v1 legacy session index file format. | ||
| * | ||
| * Single definition of the byte format of `<homeDir>/session_index.jsonl`, | ||
| * shared by the v2 writer (`sessionLifecycle` appends one line per | ||
| * create/fork) and the v2 readers (`FileSessionIndex` point lookups, | ||
| * `workspaceRegistry` one-shot rebuild). The file is a v1 interop artifact: | ||
| * v2's own source of truth is the `<sessionsDir>/<workspaceId>/<sessionId>/` | ||
| * directory tree; this file exists so a v1 CLI sharing the same homeDir can | ||
| * discover v2-created sessions, and so v2 can locate sessions the v1 way. | ||
| * | ||
| * Format (identical to v1 `packages/agent-core/src/session/store/session-index.ts`): | ||
| * append-only JSONL, one `{sessionId, sessionDir, workDir}` object per line; | ||
| * later lines override earlier ones for the same sessionId; `workDir` is | ||
| * informational only and never authoritative on read. | ||
| */ | ||
|
|
||
| import { basename, dirname, isAbsolute, relative, resolve } from 'pathe'; | ||
|
|
||
| /** Scope of the legacy index file: the homeDir root (join skips empty segments). */ | ||
| export const LEGACY_SESSION_INDEX_SCOPE = ''; | ||
| export const LEGACY_SESSION_INDEX_KEY = 'session_index.jsonl'; | ||
|
|
||
| export interface LegacySessionIndexEntry { | ||
| readonly sessionId: string; | ||
| readonly sessionDir: string; | ||
| readonly workDir: string; | ||
| } | ||
|
|
||
| /** | ||
| * Tolerantly parse one index line. Returns `undefined` for blank-ish garbage, | ||
| * non-JSON text, and entries with non-string fields — same acceptance as v1's | ||
| * `parseIndexLine`, so a corrupt line never breaks a whole read. | ||
| */ | ||
| export function parseLegacySessionIndexLine(line: string): LegacySessionIndexEntry | undefined { | ||
| try { | ||
| const parsed = JSON.parse(line) as unknown; | ||
| if (typeof parsed !== 'object' || parsed === null) return undefined; | ||
| const entry = parsed as Partial<LegacySessionIndexEntry>; | ||
| if ( | ||
| typeof entry.sessionId !== 'string' || | ||
| typeof entry.sessionDir !== 'string' || | ||
| typeof entry.workDir !== 'string' | ||
| ) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| sessionId: entry.sessionId, | ||
| sessionDir: entry.sessionDir, | ||
| workDir: entry.workDir, | ||
| }; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Validate a parsed entry against v1's read-side rules and derive the | ||
| * workspaceId from the sessionDir layout. Returns `undefined` for entries v1 | ||
| * would skip: a non-absolute `sessionDir`, one outside `sessionsDir`, or one | ||
| * whose basename does not match `sessionId`. The workspaceId is the name of | ||
| * the bucket directory the session lives in (`<sessionsDir>/<workspaceId>/<sessionId>`). | ||
| */ | ||
| export function validateLegacySessionIndexEntry( | ||
| entry: LegacySessionIndexEntry, | ||
| sessionsDir: string, | ||
| ): { sessionDir: string; workspaceId: string } | undefined { | ||
| if (!isAbsolute(entry.sessionDir)) return undefined; | ||
| const sessionDir = resolve(entry.sessionDir); | ||
| const rel = relative(resolve(sessionsDir), sessionDir); | ||
| if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return undefined; | ||
| if (basename(sessionDir) !== entry.sessionId) return undefined; | ||
| return { sessionDir, workspaceId: basename(dirname(sessionDir)) }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,10 @@ import { | |
| ISessionIndex, | ||
| PARENT_SESSION_ID_KEY, | ||
| } from '#/app/sessionIndex/sessionIndex'; | ||
| import { | ||
| LEGACY_SESSION_INDEX_KEY, | ||
| LEGACY_SESSION_INDEX_SCOPE, | ||
| } from '#/app/sessionIndex/legacySessionIndex'; | ||
| import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; | ||
| import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; | ||
| import { ITelemetryService } from '#/app/telemetry/telemetry'; | ||
|
|
@@ -82,6 +86,12 @@ import { | |
| type MaterializeSessionOptions = Omit<CreateSessionOptions, 'sessionId'> & { | ||
| readonly sessionId: string; | ||
| readonly workspaceId?: string; | ||
| /** | ||
| * Append a v1-compatible `session_index.jsonl` line once the session is | ||
| * materialized. Set for brand-new sessions (create / fork target); resume | ||
| * must not re-append (v1 only writes on create/fork). | ||
| */ | ||
| readonly legacyIndex?: boolean; | ||
| }; | ||
|
|
||
| export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { | ||
|
|
@@ -126,7 +136,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec | |
|
|
||
| async create(opts: CreateSessionOptions): Promise<ISessionScopeHandle> { | ||
| const sessionId = opts.sessionId ?? createSessionId(); | ||
| const handle = await this.materializeSession({ ...opts, sessionId }); | ||
| const handle = await this.materializeSession({ ...opts, sessionId, legacyIndex: true }); | ||
| await this.appendSessionIndexEntry(sessionId, opts.workDir); | ||
| if (this.config.get<boolean>(DEFAULT_PLAN_MODE_SECTION) === true) { | ||
| const main = await ensureMainAgent(handle); | ||
|
|
@@ -193,6 +203,21 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec | |
| void handle.accessor.get(ISessionSkillCatalog).ready; | ||
| await handle.accessor.get(IAgentLifecycleService).ensureMcpReady(); | ||
| handle.accessor.get(ISessionExternalHooksService); | ||
| if (opts.legacyIndex === true) { | ||
| // Project the new session into v1's global index so a v1 CLI sharing | ||
| // this homeDir can discover it. `AppendLogStore` framing is | ||
| // byte-identical to v1's `JSON.stringify(entry) + '\n'`; the explicit | ||
| // flush makes the line durable before create/fork returns. Placed at | ||
| // the materialization point (not fork completion) because v2 does not | ||
|
Comment on lines
+207
to
+211
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| // roll back a failed fork's directory — the tree is the index, so the | ||
| // file line matches on-disk reality. | ||
| this.appendLogStore.append(LEGACY_SESSION_INDEX_SCOPE, LEGACY_SESSION_INDEX_KEY, { | ||
| sessionId: opts.sessionId, | ||
| sessionDir, | ||
| workDir: opts.workDir, | ||
| }); | ||
| await this.appendLogStore.flush(); | ||
| } | ||
| return handle; | ||
| } | ||
|
|
||
|
|
@@ -389,6 +414,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec | |
| const target = await this.materializeSession({ | ||
| sessionId: targetId, | ||
| workDir: workspace.root, | ||
| legacyIndex: true, | ||
| }); | ||
| const targetCtx = target.accessor.get(ISessionContext); | ||
| const targetMeta = target.accessor.get(ISessionMetadata); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
<homeDir>/session_index.jsonlis present but unreadable (for example EACCES or a directory at that path),FileStorageService.readthrowsstorage.io_failed; because this new read is outside a try/catch,get()/list({ sessionId })now fail before the existing directory scan can run. v1's index reader and the helper comment both treat unreadable index files as empty, so catch read failures here and return the empty map to keep sessions discoverable from the authoritative directory tree.Useful? React with 👍 / 👎.