diff --git a/src/resources/extensions/gsd/journal.ts b/src/resources/extensions/gsd/journal.ts index 9b1fa9487..5b7003781 100644 --- a/src/resources/extensions/gsd/journal.ts +++ b/src/resources/extensions/gsd/journal.ts @@ -32,7 +32,12 @@ export type JournalEventType = | "milestone-transition" | "stuck-detected" | "sidecar-dequeue" - | "iteration-end"; + | "iteration-end" + | "worktree-enter" + | "worktree-create-failed" + | "worktree-skip" + | "worktree-merge-start" + | "worktree-merge-failed"; /** A single structured event in the journal. */ export interface JournalEntry { diff --git a/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts b/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts index 5afca834c..1b6450ee7 100644 --- a/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +++ b/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts @@ -27,7 +27,7 @@ console.log("\n=== #2330: Merge conflict stops auto loop ==="); const methodStart = resolverSrc.indexOf("Worktree-mode merge:"); assertTrue(methodStart > 0, "worktree-resolver has _mergeWorktreeMode method"); -const methodBody = resolverSrc.slice(methodStart, methodStart + 5000); +const methodBody = resolverSrc.slice(methodStart, methodStart + 6000); const rethrowsConflict = methodBody.includes("MergeConflictError") && methodBody.includes("throw err"); diff --git a/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts b/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts new file mode 100644 index 000000000..b0bb7631b --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts @@ -0,0 +1,220 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + WorktreeResolver, + type WorktreeResolverDeps, + type NotifyCtx, +} from "../worktree-resolver.js"; +import { AutoSession } from "../auto/session.js"; +import type { JournalEntry } from "../journal.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeSession( + overrides?: Partial<{ basePath: string; originalBasePath: string }>, +): AutoSession { + const s = new AutoSession(); + s.basePath = overrides?.basePath ?? "/project"; + s.originalBasePath = overrides?.originalBasePath ?? "/project"; + return s; +} + +function makeDeps( + overrides?: Partial, +): WorktreeResolverDeps { + const deps: WorktreeResolverDeps = { + isInAutoWorktree: () => false, + shouldUseWorktreeIsolation: () => true, + getIsolationMode: () => "worktree", + mergeMilestoneToMain: () => ({ pushed: false, codeFilesChanged: true }), + syncWorktreeStateBack: () => ({ synced: [] }), + teardownAutoWorktree: () => {}, + createAutoWorktree: (_basePath: string, milestoneId: string) => + `/project/.gsd/worktrees/${milestoneId}`, + enterAutoWorktree: (_basePath: string, milestoneId: string) => + `/project/.gsd/worktrees/${milestoneId}`, + getAutoWorktreePath: () => null, + autoCommitCurrentBranch: () => {}, + getCurrentBranch: () => "main", + autoWorktreeBranch: (milestoneId: string) => `milestone/${milestoneId}`, + resolveMilestoneFile: (_basePath: string, milestoneId: string) => + `/project/.gsd/milestones/${milestoneId}/${milestoneId}-ROADMAP.md`, + readFileSync: () => "# Roadmap\n- [x] S01: Slice one\n", + GitServiceImpl: class { + constructor() {} + } as unknown as WorktreeResolverDeps["GitServiceImpl"], + loadEffectiveGSDPreferences: () => ({ preferences: { git: {} } }), + invalidateAllCaches: () => {}, + captureIntegrationBranch: () => {}, + ...overrides, + }; + return deps; +} + +function makeNotifyCtx(): NotifyCtx { + return { + notify: () => {}, + }; +} + +/** Read all journal entries from a temp .gsd/journal directory. */ +function readJournalEntries(basePath: string): JournalEntry[] { + const journalDir = join(basePath, ".gsd", "journal"); + try { + const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort(); + const entries: JournalEntry[] = []; + for (const file of files) { + const raw = readFileSync(join(journalDir, file), "utf-8"); + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + entries.push(JSON.parse(line) as JournalEntry); + } + } + return entries; + } catch { + return []; + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("worktree journal events", () => { + let tmp: string; + const originalCwd = process.cwd(); + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "wt-journal-")); + }); + afterEach(() => { + // Restore cwd before cleanup — on Windows, rmSync fails with EPERM + // if the process cwd is inside the directory being deleted. + try { process.chdir(originalCwd); } catch { /* best-effort */ } + rmSync(tmp, { recursive: true, force: true }); + }); + + test("enterMilestone emits worktree-enter on success (new worktree)", () => { + const s = makeSession({ basePath: tmp, originalBasePath: tmp }); + const deps = makeDeps({ getAutoWorktreePath: () => null }); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", makeNotifyCtx()); + + const entries = readJournalEntries(tmp); + const enter = entries.find(e => e.eventType === "worktree-enter"); + assert.ok(enter, "worktree-enter event should be emitted"); + assert.equal(enter!.data?.milestoneId, "M001"); + assert.equal(enter!.data?.created, true); + assert.ok(enter!.data?.wtPath); + }); + + test("enterMilestone emits worktree-enter with created=false for existing worktree", () => { + const s = makeSession({ basePath: tmp, originalBasePath: tmp }); + const deps = makeDeps({ + getAutoWorktreePath: () => "/project/.gsd/worktrees/M001", + }); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", makeNotifyCtx()); + + const entries = readJournalEntries(tmp); + const enter = entries.find(e => e.eventType === "worktree-enter"); + assert.ok(enter, "worktree-enter event should be emitted"); + assert.equal(enter!.data?.created, false); + }); + + test("enterMilestone emits worktree-skip when isolation disabled", () => { + const s = makeSession({ basePath: tmp, originalBasePath: tmp }); + const deps = makeDeps({ shouldUseWorktreeIsolation: () => false }); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", makeNotifyCtx()); + + const entries = readJournalEntries(tmp); + const skip = entries.find(e => e.eventType === "worktree-skip"); + assert.ok(skip, "worktree-skip event should be emitted"); + assert.equal(skip!.data?.milestoneId, "M001"); + assert.equal(skip!.data?.reason, "isolation-disabled"); + }); + + test("enterMilestone emits worktree-create-failed on error", () => { + const s = makeSession({ basePath: tmp, originalBasePath: tmp }); + const deps = makeDeps({ + getAutoWorktreePath: () => null, + createAutoWorktree: () => { throw new Error("disk full"); }, + }); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", makeNotifyCtx()); + + const entries = readJournalEntries(tmp); + const failed = entries.find(e => e.eventType === "worktree-create-failed"); + assert.ok(failed, "worktree-create-failed event should be emitted"); + assert.equal(failed!.data?.milestoneId, "M001"); + assert.equal(failed!.data?.error, "disk full"); + assert.equal(failed!.data?.fallback, "project-root"); + }); + + test("mergeAndExit emits worktree-merge-start", () => { + const s = makeSession({ + basePath: join(tmp, "worktree"), + originalBasePath: tmp, + }); + const deps = makeDeps({ + isInAutoWorktree: () => true, + getIsolationMode: () => "worktree", + }); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", makeNotifyCtx()); + + const entries = readJournalEntries(tmp); + const start = entries.find(e => e.eventType === "worktree-merge-start"); + assert.ok(start, "worktree-merge-start event should be emitted"); + assert.equal(start!.data?.milestoneId, "M001"); + assert.equal(start!.data?.mode, "worktree"); + }); + + test("mergeAndExit emits worktree-merge-failed on error", () => { + const s = makeSession({ + basePath: join(tmp, "worktree"), + originalBasePath: tmp, + }); + const deps = makeDeps({ + isInAutoWorktree: () => true, + getIsolationMode: () => "worktree", + mergeMilestoneToMain: () => { throw new Error("conflict in main"); }, + }); + const resolver = new WorktreeResolver(s, deps); + + resolver.mergeAndExit("M001", makeNotifyCtx()); + + const entries = readJournalEntries(tmp); + const failed = entries.find(e => e.eventType === "worktree-merge-failed"); + assert.ok(failed, "worktree-merge-failed event should be emitted"); + assert.equal(failed!.data?.milestoneId, "M001"); + assert.equal(failed!.data?.error, "conflict in main"); + }); + + test("journal entries have valid flowId, seq, and ts fields", () => { + const s = makeSession({ basePath: tmp, originalBasePath: tmp }); + const deps = makeDeps({ shouldUseWorktreeIsolation: () => false }); + const resolver = new WorktreeResolver(s, deps); + + resolver.enterMilestone("M001", makeNotifyCtx()); + + const entries = readJournalEntries(tmp); + assert.ok(entries.length > 0, "at least one entry should exist"); + const entry = entries[0]; + assert.ok(entry.flowId, "flowId should be set"); + assert.ok( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(entry.flowId), + "flowId should be a valid UUID", + ); + assert.equal(entry.seq, 0); + assert.ok(entry.ts, "ts should be set"); + assert.ok(!isNaN(Date.parse(entry.ts)), "ts should be a valid ISO date"); + }); +}); diff --git a/src/resources/extensions/gsd/worktree-resolver.ts b/src/resources/extensions/gsd/worktree-resolver.ts index 093899297..1ebc1e920 100644 --- a/src/resources/extensions/gsd/worktree-resolver.ts +++ b/src/resources/extensions/gsd/worktree-resolver.ts @@ -14,10 +14,12 @@ */ import { existsSync, unlinkSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { join } from "node:path"; import type { AutoSession } from "./auto/session.js"; import { debugLog } from "./debug-logger.js"; import { MergeConflictError } from "./git-service.js"; +import { emitJournalEvent } from "./journal.js"; // ─── Dependency Interface ────────────────────────────────────────────────── @@ -155,6 +157,13 @@ export class WorktreeResolver { skipped: true, reason: "isolation-disabled", }); + emitJournalEvent(this.s.originalBasePath || this.s.basePath, { + ts: new Date().toISOString(), + flowId: randomUUID(), + seq: 0, + eventType: "worktree-skip", + data: { milestoneId, reason: "isolation-disabled" }, + }); return; } @@ -184,6 +193,13 @@ export class WorktreeResolver { result: "success", wtPath, }); + emitJournalEvent(this.s.originalBasePath || this.s.basePath, { + ts: new Date().toISOString(), + flowId: randomUUID(), + seq: 0, + eventType: "worktree-enter", + data: { milestoneId, wtPath, created: !existingPath }, + }); ctx.notify(`Entered worktree for ${milestoneId} at ${wtPath}`, "info"); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -193,6 +209,13 @@ export class WorktreeResolver { result: "error", error: msg, }); + emitJournalEvent(this.s.originalBasePath || this.s.basePath, { + ts: new Date().toISOString(), + flowId: randomUUID(), + seq: 0, + eventType: "worktree-create-failed", + data: { milestoneId, error: msg, fallback: "project-root" }, + }); ctx.notify( `Auto-worktree creation for ${milestoneId} failed: ${msg}. Continuing in project root.`, "warning", @@ -288,6 +311,13 @@ export class WorktreeResolver { mode, basePath: this.s.basePath, }); + emitJournalEvent(this.s.originalBasePath || this.s.basePath, { + ts: new Date().toISOString(), + flowId: randomUUID(), + seq: 0, + eventType: "worktree-merge-start", + data: { milestoneId, mode }, + }); if (mode === "none") { debugLog("WorktreeResolver", { @@ -408,6 +438,13 @@ export class WorktreeResolver { error: msg, fallback: "chdir-to-project-root", }); + emitJournalEvent(this.s.originalBasePath || this.s.basePath, { + ts: new Date().toISOString(), + flowId: randomUUID(), + seq: 0, + eventType: "worktree-merge-failed", + data: { milestoneId, error: msg }, + }); // Surface a clear, actionable error. The worktree and milestone branch are // intentionally preserved — nothing has been deleted. The user can retry // /gsd dispatch complete-milestone or merge manually once the underlying issue is fixed