mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: attribute workspace checkpoint changes (#1116)
This commit is contained in:
parent
7b61b9a778
commit
2578fde055
9 changed files with 752 additions and 16 deletions
|
|
@ -35,7 +35,9 @@ Captures are serialized per attempt and chained through `parentCheckpointId`. Ev
|
|||
|
||||
Direct parent-to-child checkpoints can be compared without touching the worktree. The bounded comparison reports affected captured files, line-numbered unified hunks, content digests, mode changes, and whether HEAD, branch, index, or Git status changed. Comparisons fail closed if either checkpoint is missing, the checkpoints are not directly chained, or their worktree ownership evidence differs.
|
||||
|
||||
This foundation does not yet claim hunk attribution, rewind conflict analysis, restore, or retention cleanup. Those layers must consume the immutable repository and remain preview-first. Rewind cannot write until current HEAD, index, file hashes, worktree ownership, external changes, and approval evidence all match the checkpoint descendant it intends to replace.
|
||||
Provider event mappers normalize bounded relative file paths and tool names into the causal journal. The attribution service considers only evidence between the two checkpoint-created events. Explicit provider file events and known path-bearing write tools are agent evidence; operator file events are operator evidence; system file events are external evidence. Every changed hunk inherits the conservative file-window attribution. Missing or mixed evidence is `unknown`, and missing checkpoint event boundaries mark the evidence window incomplete.
|
||||
|
||||
This foundation does not yet claim exact overlapping-hunk attribution, rewind conflict analysis, restore, or retention cleanup. Those layers must consume the immutable repository and remain preview-first. Rewind cannot write until current HEAD, index, file hashes, worktree ownership, external changes, and approval evidence all match the checkpoint descendant it intends to replace.
|
||||
|
||||
## Code
|
||||
|
||||
|
|
@ -44,4 +46,5 @@ This foundation does not yet claim hunk attribution, rewind conflict analysis, r
|
|||
- File repository: `server/src/storage/workspace-checkpoint-repository.ts`
|
||||
- Ownership and boundary coordination: `server/src/services/workspace-checkpoint-service.ts`
|
||||
- Read-only comparison: `server/src/services/workspace-checkpoint-diff-service.ts`
|
||||
- Focused verification: `server/src/__tests__/workspace-checkpoint-repository.test.ts` and `server/src/__tests__/workspace-checkpoint-diff-service.test.ts`
|
||||
- Conservative causal attribution: `server/src/services/workspace-checkpoint-attribution-service.ts`
|
||||
- Focused verification: `server/src/__tests__/workspace-checkpoint-repository.test.ts`, `server/src/__tests__/workspace-checkpoint-diff-service.test.ts`, and `server/src/__tests__/workspace-checkpoint-attribution-service.test.ts`
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@ describe('RunEventJournalService', () => {
|
|||
const oversized = appendInput({
|
||||
providerEventId: 'provider_oversized_1',
|
||||
payload: {
|
||||
providerType: 'item.completed',
|
||||
tool: 'Write',
|
||||
paths: ['server/src/index.ts'],
|
||||
chunks: Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) => `${index}:${'bounded provider output '.repeat(360)}`
|
||||
|
|
@ -169,6 +172,9 @@ describe('RunEventJournalService', () => {
|
|||
expect(result.event.payload).toMatchObject({
|
||||
spilled: true,
|
||||
originalPayloadBytes: expect.any(Number),
|
||||
providerType: 'item.completed',
|
||||
tool: 'Write',
|
||||
paths: ['server/src/index.ts'],
|
||||
});
|
||||
expect(result.event.payload.outputArtifact).toMatchObject({
|
||||
schemaVersion: 'run-output-preview/v1',
|
||||
|
|
@ -459,6 +465,36 @@ describe('provider run event mappers', () => {
|
|||
kind: 'tool.started',
|
||||
providerEventId: 'tool_1',
|
||||
});
|
||||
expect(
|
||||
getProviderRunEventMapper('codex-cli').mapEvent('item.completed', {
|
||||
item: {
|
||||
id: 'file_1',
|
||||
type: 'file_change',
|
||||
file_path: './server/src/index.ts',
|
||||
ignored: { path: '../outside.ts' },
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'file.changed',
|
||||
payload: { paths: ['server/src/index.ts'] },
|
||||
});
|
||||
expect(
|
||||
getProviderRunEventMapper('claude-code').mapEvent('assistant.tool_use', {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'Write',
|
||||
input: { file_path: 'docs/guide.md' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'tool.started',
|
||||
payload: { tool: 'Write', paths: ['docs/guide.md'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates retries without collapsing distinct phases that share one item ID', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,234 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
RunEventEnvelope,
|
||||
RunEventSource,
|
||||
WorkspaceCheckpointDiff,
|
||||
WorkspaceCheckpointFileDiff,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { WorkspaceCheckpointAttributionService } from '../services/workspace-checkpoint-attribution-service.js';
|
||||
|
||||
const fromCheckpointId = 'checkpoint_from1234567890123456';
|
||||
const toCheckpointId = 'checkpoint_to123456789012345678';
|
||||
const input = {
|
||||
workspaceId: 'workspace-872',
|
||||
taskId: 'task-872',
|
||||
attemptId: 'attempt-872',
|
||||
fromCheckpointId,
|
||||
toCheckpointId,
|
||||
};
|
||||
|
||||
function changedFile(path: string): WorkspaceCheckpointFileDiff {
|
||||
return {
|
||||
path,
|
||||
kind: 'modified',
|
||||
source: 'tracked',
|
||||
fromState: 'present',
|
||||
toState: 'present',
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
header: '@@ -1 +1 @@',
|
||||
oldStart: 1,
|
||||
oldLines: 1,
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
lines: [
|
||||
{ kind: 'deletion', content: 'old', oldLineNumber: 1 },
|
||||
{ kind: 'addition', content: 'new', newLineNumber: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function diff(paths: string[]): WorkspaceCheckpointDiff {
|
||||
return {
|
||||
schemaVersion: 'workspace-checkpoint-diff/v1',
|
||||
workspaceId: input.workspaceId,
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId,
|
||||
fromCheckpoint: {
|
||||
id: fromCheckpointId,
|
||||
boundary: 'before-user-turn',
|
||||
createdAt: '2026-07-26T06:00:00.000Z',
|
||||
digest: `sha256:${'1'.repeat(64)}`,
|
||||
},
|
||||
toCheckpoint: {
|
||||
id: toCheckpointId,
|
||||
boundary: 'before-user-turn',
|
||||
createdAt: '2026-07-26T06:05:00.000Z',
|
||||
digest: `sha256:${'2'.repeat(64)}`,
|
||||
},
|
||||
directParent: true,
|
||||
git: {
|
||||
headChanged: false,
|
||||
branchChanged: false,
|
||||
indexChanged: true,
|
||||
statusChanged: true,
|
||||
},
|
||||
summary: { filesChanged: paths.length, additions: paths.length, deletions: paths.length },
|
||||
files: paths.map(changedFile),
|
||||
};
|
||||
}
|
||||
|
||||
function event(
|
||||
sequence: number,
|
||||
kind: string,
|
||||
source: RunEventSource,
|
||||
payload: RunEventEnvelope['payload']
|
||||
): RunEventEnvelope {
|
||||
return {
|
||||
schemaVersion: 'run-event/v1',
|
||||
eventId: `event-${sequence}`,
|
||||
taskId: input.taskId,
|
||||
runId: input.attemptId,
|
||||
attemptId: input.attemptId,
|
||||
sequence,
|
||||
receivedAt: `2026-07-26T06:00:0${sequence}.000Z`,
|
||||
kind,
|
||||
source,
|
||||
redaction: { status: 'none', fields: [], originalBytes: 0, persistedBytes: 0 },
|
||||
payload,
|
||||
payloadHash: `${sequence}`,
|
||||
};
|
||||
}
|
||||
|
||||
const systemSource = { provider: 'system', adapter: 'workspace-checkpoint' } as const;
|
||||
const codexSource = { provider: 'codex-cli', adapter: 'codex-cli', agent: 'CODEX' } as const;
|
||||
const claudeSource = {
|
||||
provider: 'claude-code',
|
||||
adapter: 'claude-code',
|
||||
agent: 'CLAUDE',
|
||||
} as const;
|
||||
const operatorSource = { provider: 'operator', adapter: 'operator-file-editor' } as const;
|
||||
|
||||
describe('WorkspaceCheckpointAttributionService', () => {
|
||||
it('attributes only explicit path-bearing evidence inside checkpoint boundaries', async () => {
|
||||
const paths = [
|
||||
'agent.ts',
|
||||
'operator.ts',
|
||||
'external.ts',
|
||||
'write-tool.ts',
|
||||
'mixed.ts',
|
||||
'unknown.ts',
|
||||
];
|
||||
const events = [
|
||||
event(1, 'workspace.checkpoint.created', systemSource, {
|
||||
checkpointId: fromCheckpointId,
|
||||
}),
|
||||
event(2, 'file.changed', codexSource, { paths: ['agent.ts'] }),
|
||||
event(3, 'file.changed', operatorSource, { paths: ['operator.ts'] }),
|
||||
event(
|
||||
4,
|
||||
'file.changed',
|
||||
{ provider: 'system', adapter: 'filesystem-watcher' },
|
||||
{
|
||||
paths: ['external.ts'],
|
||||
}
|
||||
),
|
||||
event(5, 'tool.started', claudeSource, {
|
||||
tool: 'Write',
|
||||
paths: ['write-tool.ts'],
|
||||
}),
|
||||
event(6, 'file.changed', codexSource, { paths: ['mixed.ts'] }),
|
||||
event(7, 'file.changed', operatorSource, { paths: ['mixed.ts'] }),
|
||||
event(8, 'tool.started', claudeSource, { tool: 'Read', paths: ['unknown.ts'] }),
|
||||
event(9, 'workspace.checkpoint.created', systemSource, { checkpointId: toCheckpointId }),
|
||||
];
|
||||
const service = new WorkspaceCheckpointAttributionService({
|
||||
diffs: { compare: vi.fn(async () => diff(paths)) },
|
||||
events: {
|
||||
list: vi.fn(async () => ({
|
||||
schemaVersion: 'run-event/v1',
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId,
|
||||
events,
|
||||
nextCursor: 9,
|
||||
hasMore: false,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.compare(input);
|
||||
const attribution = Object.fromEntries(
|
||||
result.files.map((file) => [file.path, file.attribution])
|
||||
);
|
||||
|
||||
expect(result.attribution).toEqual({
|
||||
evidenceComplete: true,
|
||||
fromEventSequence: 1,
|
||||
toEventSequence: 9,
|
||||
eventsConsidered: 7,
|
||||
});
|
||||
expect(attribution['agent.ts']).toMatchObject({
|
||||
source: 'agent-tool',
|
||||
confidence: 'high',
|
||||
basis: 'provider-file-event',
|
||||
scope: 'checkpoint-file-window',
|
||||
provider: 'codex-cli',
|
||||
agent: 'CODEX',
|
||||
});
|
||||
expect(attribution['operator.ts']).toMatchObject({
|
||||
source: 'operator',
|
||||
basis: 'operator-file-event',
|
||||
});
|
||||
expect(attribution['external.ts']).toMatchObject({
|
||||
source: 'external',
|
||||
basis: 'filesystem-file-event',
|
||||
});
|
||||
expect(attribution['write-tool.ts']).toMatchObject({
|
||||
source: 'agent-tool',
|
||||
basis: 'write-tool-event',
|
||||
tool: 'Write',
|
||||
});
|
||||
expect(attribution['mixed.ts']).toEqual({
|
||||
source: 'unknown',
|
||||
confidence: 'ambiguous',
|
||||
basis: 'mixed-file-evidence',
|
||||
scope: 'checkpoint-file-window',
|
||||
evidenceEventIds: ['event-6', 'event-7'],
|
||||
});
|
||||
expect(attribution['unknown.ts']).toEqual({
|
||||
source: 'unknown',
|
||||
confidence: 'none',
|
||||
basis: 'no-file-evidence',
|
||||
scope: 'checkpoint-file-window',
|
||||
evidenceEventIds: [],
|
||||
});
|
||||
expect(result.files[0].hunks[0].attribution).toEqual(result.files[0].attribution);
|
||||
});
|
||||
|
||||
it('marks all attribution unknown when the event boundary window is incomplete', async () => {
|
||||
const service = new WorkspaceCheckpointAttributionService({
|
||||
diffs: { compare: vi.fn(async () => diff(['agent.ts'])) },
|
||||
events: {
|
||||
list: vi.fn(async () => ({
|
||||
schemaVersion: 'run-event/v1',
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId,
|
||||
events: [
|
||||
event(1, 'workspace.checkpoint.created', systemSource, {
|
||||
checkpointId: fromCheckpointId,
|
||||
}),
|
||||
event(2, 'workspace.checkpoint.created', codexSource, {
|
||||
checkpointId: toCheckpointId,
|
||||
}),
|
||||
event(3, 'file.changed', codexSource, { paths: ['agent.ts'] }),
|
||||
],
|
||||
nextCursor: 3,
|
||||
hasMore: false,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.compare(input);
|
||||
|
||||
expect(result.attribution).toEqual({
|
||||
evidenceComplete: false,
|
||||
fromEventSequence: 1,
|
||||
eventsConsidered: 0,
|
||||
});
|
||||
expect(result.files[0].attribution?.source).toBe('unknown');
|
||||
});
|
||||
});
|
||||
117
server/src/services/provider-event-evidence.ts
Normal file
117
server/src/services/provider-event-evidence.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
const FILE_PATH_KEYS = new Set([
|
||||
'file',
|
||||
'file_path',
|
||||
'filePath',
|
||||
'filepath',
|
||||
'notebook_path',
|
||||
'notebookPath',
|
||||
'path',
|
||||
'relative_path',
|
||||
'relativePath',
|
||||
]);
|
||||
|
||||
export function extractProviderEventPaths(value: unknown): string[] {
|
||||
const paths = new Set<string>();
|
||||
const seen = new Set<object>();
|
||||
const visit = (candidate: unknown, key: string | undefined, depth: number): void => {
|
||||
if (depth > 8 || paths.size >= 100) return;
|
||||
if (typeof candidate === 'string') {
|
||||
if (!key || !FILE_PATH_KEYS.has(key)) return;
|
||||
const normalized = normalizeWorkspaceEvidencePath(candidate);
|
||||
if (normalized) paths.add(normalized);
|
||||
return;
|
||||
}
|
||||
if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) return;
|
||||
seen.add(candidate);
|
||||
if (Array.isArray(candidate)) {
|
||||
for (const entry of candidate.slice(0, 100)) visit(entry, key, depth + 1);
|
||||
return;
|
||||
}
|
||||
for (const [childKey, childValue] of Object.entries(candidate as Record<string, unknown>).slice(
|
||||
0,
|
||||
256
|
||||
)) {
|
||||
visit(childValue, childKey, depth + 1);
|
||||
if (paths.size >= 100) break;
|
||||
}
|
||||
};
|
||||
visit(value, undefined, 0);
|
||||
return [...paths].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function extractProviderEventToolName(value: unknown): string | undefined {
|
||||
const seen = new Set<object>();
|
||||
const visit = (candidate: unknown, depth: number): string | undefined => {
|
||||
if (depth > 8 || !candidate || typeof candidate !== 'object' || seen.has(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
seen.add(candidate);
|
||||
if (Array.isArray(candidate)) {
|
||||
for (const entry of candidate.slice(0, 100)) {
|
||||
const nested = visit(entry, depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const record = candidate as Record<string, unknown>;
|
||||
const type = typeof record.type === 'string' ? record.type.toLowerCase() : '';
|
||||
for (const key of ['tool', 'tool_name', 'toolName', 'name']) {
|
||||
const name = record[key];
|
||||
if (
|
||||
typeof name === 'string' &&
|
||||
name.length <= 240 &&
|
||||
!hasControlCharacters(name) &&
|
||||
(key !== 'name' || type.includes('tool') || type.includes('file'))
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
for (const child of Object.values(record).slice(0, 256)) {
|
||||
const nested = visit(child, depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return visit(value, 0);
|
||||
}
|
||||
|
||||
export function isWriteCapableProviderTool(toolName: string | undefined): boolean {
|
||||
if (!toolName) return false;
|
||||
const normalized = toolName
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_');
|
||||
return /(?:^|_)(?:write|edit|multi_edit|notebook_edit|apply_patch|applypatch|patch|replace|create_file|delete_file|rename_file|move_file)(?:_|$)/.test(
|
||||
normalized
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceEvidencePath(value: string): string | undefined {
|
||||
const trimmed = value.trim().replaceAll('\\', '/');
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.length > 2_048 ||
|
||||
hasControlCharacters(trimmed) ||
|
||||
trimmed.startsWith('/') ||
|
||||
/^[A-Za-z]:\//.test(trimmed) ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const segments = trimmed.replace(/^(?:\.\/)+/, '').split('/');
|
||||
if (
|
||||
segments.length === 0 ||
|
||||
segments.some((segment) => !segment || segment === '.' || segment === '..')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import type { ExecutableAgentProvider, RunEventKind } from '@veritas-kanban/shared';
|
||||
import {
|
||||
extractProviderEventPaths,
|
||||
extractProviderEventToolName,
|
||||
} from './provider-event-evidence.js';
|
||||
|
||||
export interface ProviderMappedRunEvent {
|
||||
kind: RunEventKind;
|
||||
|
|
@ -51,6 +55,25 @@ function nestedRecord(value: unknown): Record<string, unknown> | undefined {
|
|||
: undefined;
|
||||
}
|
||||
|
||||
function providerPayload(
|
||||
providerType: string,
|
||||
event: Record<string, unknown>,
|
||||
summary: string | undefined,
|
||||
kind: RunEventKind
|
||||
): Record<string, unknown> {
|
||||
const carriesFileEvidence =
|
||||
kind === 'file.changed' || kind === 'tool.started' || kind === 'tool.completed';
|
||||
const paths = carriesFileEvidence ? extractProviderEventPaths(event) : [];
|
||||
const tool = carriesFileEvidence ? extractProviderEventToolName(event) : undefined;
|
||||
return {
|
||||
providerType,
|
||||
summary,
|
||||
...(paths.length > 0 ? { paths } : {}),
|
||||
...(tool ? { tool } : {}),
|
||||
raw: event,
|
||||
};
|
||||
}
|
||||
|
||||
function eventIdentity(event: Record<string, unknown>): {
|
||||
providerEventId?: string;
|
||||
sessionId?: string;
|
||||
|
|
@ -166,15 +189,12 @@ function codexMapper(
|
|||
},
|
||||
mapEvent(providerType, event, summary) {
|
||||
const identity = eventIdentity(event);
|
||||
const kind = itemKind(providerType, event);
|
||||
return {
|
||||
...identity,
|
||||
kind: itemKind(providerType, event),
|
||||
kind,
|
||||
dedupeKey: providerDedupeKey(provider, providerType, identity.providerEventId),
|
||||
payload: {
|
||||
providerType,
|
||||
summary,
|
||||
raw: event,
|
||||
},
|
||||
payload: providerPayload(providerType, event, summary, kind),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -189,11 +209,12 @@ const HERMES_MAPPER: ProviderRunEventMapper = {
|
|||
},
|
||||
mapEvent(providerType, event, summary) {
|
||||
const identity = eventIdentity(event);
|
||||
const kind = itemKind(providerType, event);
|
||||
return {
|
||||
...identity,
|
||||
kind: itemKind(providerType, event),
|
||||
kind,
|
||||
dedupeKey: providerDedupeKey('hermes-cli', providerType, identity.providerEventId),
|
||||
payload: { providerType, summary, raw: event },
|
||||
payload: providerPayload(providerType, event, summary, kind),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -230,11 +251,12 @@ const CLAUDE_CODE_MAPPER: ProviderRunEventMapper = {
|
|||
},
|
||||
mapEvent(providerType, event, summary) {
|
||||
const identity = eventIdentity(event);
|
||||
const kind = claudeCodeKind(providerType);
|
||||
return {
|
||||
...identity,
|
||||
kind: claudeCodeKind(providerType),
|
||||
kind,
|
||||
dedupeKey: providerDedupeKey('claude-code', providerType, identity.providerEventId),
|
||||
payload: { providerType, summary, raw: event },
|
||||
payload: providerPayload(providerType, event, summary, kind),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -248,11 +270,12 @@ const OPENCLAW_MAPPER: ProviderRunEventMapper = {
|
|||
},
|
||||
mapEvent(providerType, event, summary) {
|
||||
const identity = eventIdentity(event);
|
||||
const kind = itemKind(providerType, event);
|
||||
return {
|
||||
...identity,
|
||||
kind: itemKind(providerType, event),
|
||||
kind,
|
||||
dedupeKey: providerDedupeKey('openclaw', providerType, identity.providerEventId),
|
||||
payload: { providerType, summary, raw: event },
|
||||
payload: providerPayload(providerType, event, summary, kind),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -266,11 +289,12 @@ const ACP_MAPPER: ProviderRunEventMapper = {
|
|||
},
|
||||
mapEvent(providerType, event, summary) {
|
||||
const identity = eventIdentity(event);
|
||||
const kind = itemKind(providerType, event);
|
||||
return {
|
||||
...identity,
|
||||
kind: itemKind(providerType, event),
|
||||
kind,
|
||||
dedupeKey: providerDedupeKey('acp-stdio', providerType, identity.providerEventId),
|
||||
payload: { providerType, summary, raw: event },
|
||||
payload: providerPayload(providerType, event, summary, kind),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -196,6 +196,18 @@ function spillSourceName(payload: Record<string, unknown>): string | undefined {
|
|||
return candidate ? redactString(candidate).slice(0, 240) : undefined;
|
||||
}
|
||||
|
||||
function spillRoutingEvidence(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const evidence: Record<string, unknown> = {};
|
||||
if (typeof payload.providerType === 'string') evidence.providerType = payload.providerType;
|
||||
if (typeof payload.tool === 'string') evidence.tool = payload.tool;
|
||||
if (Array.isArray(payload.paths)) {
|
||||
evidence.paths = payload.paths
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.slice(0, 100);
|
||||
}
|
||||
return evidence;
|
||||
}
|
||||
|
||||
function deterministicArtifactId(input: RunEventAppendInput, eventId: string): string {
|
||||
const identity =
|
||||
input.dedupeKey ??
|
||||
|
|
@ -261,6 +273,7 @@ export class RunEventJournalService {
|
|||
: undefined;
|
||||
const eventPayload = spillPreview
|
||||
? {
|
||||
...sanitizePayload(spillRoutingEvidence(input.payload)).payload,
|
||||
spilled: true,
|
||||
originalPayloadBytes: countBytes(serialized ?? ''),
|
||||
outputArtifact: spillPreview,
|
||||
|
|
|
|||
278
server/src/services/workspace-checkpoint-attribution-service.ts
Normal file
278
server/src/services/workspace-checkpoint-attribution-service.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import type {
|
||||
RunEventEnvelope,
|
||||
RunEventPage,
|
||||
RunEventQuery,
|
||||
WorkspaceCheckpointDiff,
|
||||
WorkspaceCheckpointFileDiff,
|
||||
WorkspaceCheckpointHunkAttribution,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { ConflictError } from '../middleware/error-handler.js';
|
||||
import {
|
||||
extractProviderEventPaths,
|
||||
extractProviderEventToolName,
|
||||
isWriteCapableProviderTool,
|
||||
normalizeWorkspaceEvidencePath,
|
||||
} from './provider-event-evidence.js';
|
||||
import { RunEventJournalService } from './run-event-journal-service.js';
|
||||
import {
|
||||
WorkspaceCheckpointDiffService,
|
||||
type WorkspaceCheckpointDiffInput,
|
||||
} from './workspace-checkpoint-diff-service.js';
|
||||
|
||||
const MAX_EVENT_PAGES = 100;
|
||||
|
||||
export interface WorkspaceCheckpointEventSource {
|
||||
list(query: RunEventQuery): Promise<RunEventPage>;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointAttributionServiceOptions {
|
||||
diffs?: Pick<WorkspaceCheckpointDiffService, 'compare'>;
|
||||
events?: WorkspaceCheckpointEventSource;
|
||||
}
|
||||
|
||||
interface CheckpointEventWindow {
|
||||
evidenceComplete: boolean;
|
||||
fromEventSequence?: number;
|
||||
toEventSequence?: number;
|
||||
events: RunEventEnvelope[];
|
||||
}
|
||||
|
||||
interface AttributionEvidence {
|
||||
source: Exclude<WorkspaceCheckpointHunkAttribution['source'], 'unknown'>;
|
||||
basis: Exclude<
|
||||
WorkspaceCheckpointHunkAttribution['basis'],
|
||||
'mixed-file-evidence' | 'no-file-evidence'
|
||||
>;
|
||||
event: RunEventEnvelope;
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
export class WorkspaceCheckpointAttributionService {
|
||||
private readonly diffs: Pick<WorkspaceCheckpointDiffService, 'compare'>;
|
||||
private readonly events: WorkspaceCheckpointEventSource;
|
||||
|
||||
constructor(options: WorkspaceCheckpointAttributionServiceOptions = {}) {
|
||||
this.diffs = options.diffs ?? new WorkspaceCheckpointDiffService();
|
||||
this.events = options.events ?? new RunEventJournalService();
|
||||
}
|
||||
|
||||
async compare(input: WorkspaceCheckpointDiffInput): Promise<WorkspaceCheckpointDiff> {
|
||||
const diff = await this.diffs.compare(input);
|
||||
const window = await this.loadEventWindow(input);
|
||||
const evidenceByPath = this.indexEvidence(window.events);
|
||||
const files = diff.files.map((file) =>
|
||||
this.attributeFile(file, evidenceByPath.get(file.path) ?? [])
|
||||
);
|
||||
return {
|
||||
...diff,
|
||||
attribution: {
|
||||
evidenceComplete: window.evidenceComplete,
|
||||
...(window.fromEventSequence === undefined
|
||||
? {}
|
||||
: { fromEventSequence: window.fromEventSequence }),
|
||||
...(window.toEventSequence === undefined
|
||||
? {}
|
||||
: { toEventSequence: window.toEventSequence }),
|
||||
eventsConsidered: window.events.length,
|
||||
},
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadEventWindow(
|
||||
input: WorkspaceCheckpointDiffInput
|
||||
): Promise<CheckpointEventWindow> {
|
||||
let cursor = 0;
|
||||
let fromEventSequence: number | undefined;
|
||||
let toEventSequence: number | undefined;
|
||||
const events: RunEventEnvelope[] = [];
|
||||
for (let pageNumber = 0; pageNumber < MAX_EVENT_PAGES; pageNumber += 1) {
|
||||
const page = await this.events.list({
|
||||
taskId: input.taskId,
|
||||
attemptId: input.attemptId,
|
||||
afterSequence: cursor,
|
||||
limit: 500,
|
||||
});
|
||||
for (const event of page.events) {
|
||||
const checkpointId = trustedCheckpointEventId(event);
|
||||
if (checkpointId === input.fromCheckpointId) {
|
||||
fromEventSequence = event.sequence;
|
||||
events.length = 0;
|
||||
continue;
|
||||
}
|
||||
if (checkpointId === input.toCheckpointId) {
|
||||
if (fromEventSequence === undefined) {
|
||||
throw new ConflictError('Workspace checkpoint event boundaries are out of order.');
|
||||
}
|
||||
toEventSequence = event.sequence;
|
||||
break;
|
||||
}
|
||||
if (fromEventSequence !== undefined) events.push(event);
|
||||
}
|
||||
if (toEventSequence !== undefined) break;
|
||||
if (!page.hasMore) break;
|
||||
if (page.nextCursor <= cursor || page.events.length === 0) {
|
||||
throw new ConflictError('Workspace checkpoint event replay did not advance.');
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
|
||||
if (
|
||||
fromEventSequence !== undefined &&
|
||||
toEventSequence !== undefined &&
|
||||
fromEventSequence >= toEventSequence
|
||||
) {
|
||||
throw new ConflictError('Workspace checkpoint event boundaries are out of order.');
|
||||
}
|
||||
if (fromEventSequence === undefined || toEventSequence === undefined) {
|
||||
return {
|
||||
evidenceComplete: false,
|
||||
fromEventSequence,
|
||||
toEventSequence,
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
evidenceComplete: true,
|
||||
fromEventSequence,
|
||||
toEventSequence,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
private attributeFile(
|
||||
file: WorkspaceCheckpointFileDiff,
|
||||
evidence: AttributionEvidence[]
|
||||
): WorkspaceCheckpointFileDiff {
|
||||
const attribution = summarizeAttribution(evidence);
|
||||
return {
|
||||
...file,
|
||||
attribution,
|
||||
hunks: file.hunks.map((hunk) => ({ ...hunk, attribution })),
|
||||
};
|
||||
}
|
||||
|
||||
private indexEvidence(events: RunEventEnvelope[]): Map<string, AttributionEvidence[]> {
|
||||
const byPath = new Map<string, AttributionEvidence[]>();
|
||||
for (const event of events) {
|
||||
const evidence = this.evidenceForEvent(event);
|
||||
if (!evidence) continue;
|
||||
for (const filePath of normalizedEventPaths(event)) {
|
||||
const existing = byPath.get(filePath) ?? [];
|
||||
existing.push(evidence);
|
||||
byPath.set(filePath, existing);
|
||||
}
|
||||
}
|
||||
return byPath;
|
||||
}
|
||||
|
||||
private evidenceForEvent(event: RunEventEnvelope): AttributionEvidence | undefined {
|
||||
if (event.kind === 'file.changed') {
|
||||
if (event.source.provider === 'operator') {
|
||||
return { source: 'operator', basis: 'operator-file-event', event };
|
||||
}
|
||||
if (event.source.provider === 'system') {
|
||||
return { source: 'external', basis: 'filesystem-file-event', event };
|
||||
}
|
||||
return { source: 'agent-tool', basis: 'provider-file-event', event };
|
||||
}
|
||||
|
||||
if (event.kind === 'tool.started' || event.kind === 'tool.completed') {
|
||||
const tool = normalizedEventTool(event);
|
||||
if (!isWriteCapableProviderTool(tool)) return undefined;
|
||||
if (event.source.provider === 'operator') {
|
||||
return { source: 'operator', basis: 'operator-file-event', event, tool };
|
||||
}
|
||||
if (event.source.provider === 'system') {
|
||||
return { source: 'external', basis: 'filesystem-file-event', event, tool };
|
||||
}
|
||||
return { source: 'agent-tool', basis: 'write-tool-event', event, tool };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedEventPaths(event: RunEventEnvelope): string[] {
|
||||
const normalized = event.payload.paths;
|
||||
if (Array.isArray(normalized)) {
|
||||
return normalized.flatMap((entry) => {
|
||||
if (typeof entry !== 'string') return [];
|
||||
const path = normalizeWorkspaceEvidencePath(entry);
|
||||
return path ? [path] : [];
|
||||
});
|
||||
}
|
||||
return extractProviderEventPaths(event.payload);
|
||||
}
|
||||
|
||||
function normalizedEventTool(event: RunEventEnvelope): string | undefined {
|
||||
return typeof event.payload.tool === 'string'
|
||||
? event.payload.tool
|
||||
: extractProviderEventToolName(event.payload);
|
||||
}
|
||||
|
||||
function summarizeAttribution(evidence: AttributionEvidence[]): WorkspaceCheckpointHunkAttribution {
|
||||
if (evidence.length === 0) {
|
||||
return {
|
||||
source: 'unknown',
|
||||
confidence: 'none',
|
||||
basis: 'no-file-evidence',
|
||||
scope: 'checkpoint-file-window',
|
||||
evidenceEventIds: [],
|
||||
};
|
||||
}
|
||||
const sources = new Set(evidence.map((entry) => entry.source));
|
||||
const eventIds = [...new Set(evidence.map((entry) => entry.event.eventId))].slice(0, 100);
|
||||
if (sources.size !== 1) {
|
||||
return {
|
||||
source: 'unknown',
|
||||
confidence: 'ambiguous',
|
||||
basis: 'mixed-file-evidence',
|
||||
scope: 'checkpoint-file-window',
|
||||
evidenceEventIds: eventIds,
|
||||
};
|
||||
}
|
||||
const source = evidence[0].source;
|
||||
const bases = new Set(evidence.map((entry) => entry.basis));
|
||||
const providers = new Set(evidence.map((entry) => entry.event.source.provider));
|
||||
const agents = new Set(
|
||||
evidence
|
||||
.map((entry) => entry.event.source.agent)
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
);
|
||||
const tools = new Set(
|
||||
evidence.map((entry) => entry.tool).filter((entry): entry is string => Boolean(entry))
|
||||
);
|
||||
const provider = [...providers][0];
|
||||
const agent = [...agents][0];
|
||||
const tool = [...tools][0];
|
||||
return {
|
||||
source,
|
||||
confidence: 'high',
|
||||
basis: bases.size === 1 ? evidence[0].basis : sourceBasis(source),
|
||||
scope: 'checkpoint-file-window',
|
||||
evidenceEventIds: eventIds,
|
||||
...(providers.size === 1 && provider ? { provider } : {}),
|
||||
...(agents.size === 1 && agent ? { agent } : {}),
|
||||
...(tools.size === 1 && tool ? { tool } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function trustedCheckpointEventId(event: RunEventEnvelope): string | undefined {
|
||||
return event.kind === 'workspace.checkpoint.created' &&
|
||||
event.source.provider === 'system' &&
|
||||
event.source.adapter === 'workspace-checkpoint' &&
|
||||
typeof event.payload.checkpointId === 'string'
|
||||
? event.payload.checkpointId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function sourceBasis(
|
||||
source: Exclude<WorkspaceCheckpointHunkAttribution['source'], 'unknown'>
|
||||
): Exclude<
|
||||
WorkspaceCheckpointHunkAttribution['basis'],
|
||||
'mixed-file-evidence' | 'no-file-evidence'
|
||||
> {
|
||||
if (source === 'operator') return 'operator-file-event';
|
||||
if (source === 'external') return 'filesystem-file-event';
|
||||
return 'provider-file-event';
|
||||
}
|
||||
|
|
@ -92,8 +92,10 @@
|
|||
"stream.stdout",
|
||||
"stream.stderr",
|
||||
"command.started",
|
||||
"command.detached",
|
||||
"command.completed",
|
||||
"file.changed",
|
||||
"workspace.checkpoint.created",
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"approval.requested",
|
||||
|
|
|
|||
|
|
@ -93,6 +93,27 @@ export interface WorkspaceCheckpoint {
|
|||
|
||||
export type WorkspaceCheckpointDiffLineKind = 'context' | 'addition' | 'deletion';
|
||||
export type WorkspaceCheckpointFileChangeKind = 'added' | 'modified' | 'deleted' | 'mode-changed';
|
||||
export type WorkspaceCheckpointAttributionSource =
|
||||
'agent-tool' | 'operator' | 'external' | 'unknown';
|
||||
export type WorkspaceCheckpointAttributionConfidence = 'high' | 'ambiguous' | 'none';
|
||||
export type WorkspaceCheckpointAttributionBasis =
|
||||
| 'provider-file-event'
|
||||
| 'write-tool-event'
|
||||
| 'operator-file-event'
|
||||
| 'filesystem-file-event'
|
||||
| 'mixed-file-evidence'
|
||||
| 'no-file-evidence';
|
||||
|
||||
export interface WorkspaceCheckpointHunkAttribution {
|
||||
source: WorkspaceCheckpointAttributionSource;
|
||||
confidence: WorkspaceCheckpointAttributionConfidence;
|
||||
basis: WorkspaceCheckpointAttributionBasis;
|
||||
scope: 'checkpoint-file-window';
|
||||
evidenceEventIds: string[];
|
||||
provider?: string;
|
||||
agent?: string;
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointDiffLine {
|
||||
kind: WorkspaceCheckpointDiffLineKind;
|
||||
|
|
@ -108,6 +129,7 @@ export interface WorkspaceCheckpointDiffHunk {
|
|||
newStart: number;
|
||||
newLines: number;
|
||||
lines: WorkspaceCheckpointDiffLine[];
|
||||
attribution?: WorkspaceCheckpointHunkAttribution;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointFileDiff {
|
||||
|
|
@ -123,6 +145,7 @@ export interface WorkspaceCheckpointFileDiff {
|
|||
additions: number;
|
||||
deletions: number;
|
||||
hunks: WorkspaceCheckpointDiffHunk[];
|
||||
attribution?: WorkspaceCheckpointHunkAttribution;
|
||||
}
|
||||
|
||||
export interface WorkspaceCheckpointDiff {
|
||||
|
|
@ -144,5 +167,11 @@ export interface WorkspaceCheckpointDiff {
|
|||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
attribution?: {
|
||||
evidenceComplete: boolean;
|
||||
fromEventSequence?: number;
|
||||
toEventSequence?: number;
|
||||
eventsConsidered: number;
|
||||
};
|
||||
files: WorkspaceCheckpointFileDiff[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue