diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index a84d570d..ac1c68d2 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -9,7 +9,7 @@ import { } from '@shared/utils/taskChangeState'; import { createHash } from 'crypto'; import { existsSync } from 'fs'; -import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises'; +import { chmod, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; @@ -77,6 +77,7 @@ interface OpenCodeBackfillAttempt { interface OpenCodeDeliveryContextTempFile { filePath: string | null; + hash: string | null; cleanup: () => Promise; } @@ -513,7 +514,12 @@ export class ChangeExtractorService { workspaceRoot, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, ...(backfillMemberName ? { memberName: backfillMemberName } : {}), - ...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}), + ...(deliveryContext.filePath + ? { + deliveryContextPath: deliveryContext.filePath, + deliveryContextHash: deliveryContext.hash ?? undefined, + } + : {}), }); void appendOpenCodeTaskChangeDiag({ event: 'backfill_result', @@ -661,27 +667,26 @@ export class ChangeExtractorService { records: Awaited> ): Promise { if (records.length === 0) { - return { filePath: null, cleanup: async () => undefined }; + return { filePath: null, hash: null, cleanup: async () => undefined }; } const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-team-opencode-ledger-context-')); + await chmod(dir, 0o700).catch(() => undefined); const filePath = path.join(dir, 'delivery-context.json'); - await writeFile( - filePath, - `${JSON.stringify( - { - schemaVersion: 1, - teamName, - taskId, - records, - }, - null, - 2 - )}\n`, - { encoding: 'utf8', mode: 0o600 } - ); + const rawContext = `${JSON.stringify( + { + schemaVersion: 1, + teamName, + taskId, + records, + }, + null, + 2 + )}\n`; + await writeFile(filePath, rawContext, { encoding: 'utf8', mode: 0o600 }); return { filePath, + hash: createHash('sha256').update(rawContext).digest('hex'), cleanup: async () => { await rm(dir, { recursive: true, force: true }).catch(() => undefined); }, diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index eca8e93a..9a478e01 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -239,6 +239,7 @@ export interface OpenCodeBackfillTaskLedgerCommandBody { projectDir?: string; workspaceRoot?: string; deliveryContextPath?: string; + deliveryContextHash?: string; attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode; dryRun?: boolean; } diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 19ca95c6..daaa5428 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -1,5 +1,6 @@ import * as os from 'os'; import * as path from 'path'; +import { createHash } from 'crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'fs/promises'; @@ -999,7 +1000,12 @@ describe('ChangeExtractorService', () => { await fs.mkdir(projectPath, { recursive: true }); await writeOpenCodeDeliveryLedger(tmpDir); + let deliveryContextHashVerified = false; const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => { + deliveryContextHashVerified = + createHash('sha256') + .update(await fs.readFile(input.deliveryContextPath, 'utf8')) + .digest('hex') === input.deliveryContextHash; await writeOpenCodeLedgerBundle(input.projectDir, projectPath); return { schemaVersion: 1, @@ -1069,6 +1075,12 @@ describe('ChangeExtractorService', () => { attributionMode: 'strict-delivery', }) ); + const backfillInput = backfillOpenCodeTaskLedger.mock.calls[0]?.[0]; + expect(backfillInput.deliveryContextPath).toEqual( + expect.stringContaining('delivery-context.json') + ); + expect(backfillInput.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); + expect(deliveryContextHashVerified).toBe(true); expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('evidenceMode'); expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); }); @@ -1513,6 +1525,7 @@ describe('ChangeExtractorService', () => { projectDir, workspaceRoot: projectPath, deliveryContextPath: expect.stringContaining('delivery-context.json'), + deliveryContextHash: expect.stringMatching(/^[a-f0-9]{64}$/), attributionMode: 'strict-delivery', }) ); @@ -1621,6 +1634,7 @@ describe('ChangeExtractorService', () => { expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); + expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); }); it('does not cache negative OpenCode backfill while delivery context already exists', async () => { @@ -1732,8 +1746,10 @@ describe('ChangeExtractorService', () => { expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); + expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); + expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); }); }); diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 26079377..090a4bd7 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -170,6 +170,8 @@ describe('OpenCodeReadinessBridge', () => { taskDisplayId: 'abc12345', projectDir: '/claude/project', workspaceRoot: '/repo', + deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json', + deliveryContextHash: 'a'.repeat(64), }) ).resolves.toMatchObject({ outcome: 'imported', @@ -184,6 +186,8 @@ describe('OpenCodeReadinessBridge', () => { taskDisplayId: 'abc12345', projectDir: '/claude/project', workspaceRoot: '/repo', + deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json', + deliveryContextHash: 'a'.repeat(64), }, { cwd: '/repo',