Skip to content

Commit 1e29d11

Browse files
khaliqgantclaude
andcommitted
fix(sdk): align WorkflowTrajectory writer with canonical layout + fields
The SDK's WorkflowTrajectory is a standalone writer that emits trajectory JSON without using the `agent-trajectories` library. Its output diverged from the canonical layout in two visible ways: 1. Completed files landed in a flat `completed/` root instead of `completed/YYYY-MM/{id}.json`, forcing downstream readers to grow legacy-layout fallbacks. 2. Top-level `commits`, `filesChanged`, and `tags` arrays were omitted, even though the canonical schema declares them. Readers had to default them at load time. These are now aligned: - `moveToCompleted()` computes a `YYYY-MM` bucket from `completedAt` (falling back to `startedAt`) and writes into `completed/<bucket>/{id}.json`. - `TrajectoryFile` interface declares `commits: string[]`, `filesChanged: string[]`, `tags: string[]`, and `start()` initializes them to empty arrays. Canonical output is defense-in-depth — `agent-trajectories` PR #22 (AgentWorkforce/trajectories#22) makes the reader tolerant of the prior shapes, so this change is not urgent. It does let the reader shed those fallbacks over time. Test coverage: - Existing `readCompletedTrajectoryFile` helper now walks completed/ recursively so tests don't hardcode the bucket name. - New: asserts the completed file path has exactly one `YYYY-MM` intermediate directory. - New: asserts `commits`/`filesChanged`/`tags` are populated as [] on `start()`. All 29 tests in `workflow-trajectory.test.ts` pass. Diff scoped to two files; unrelated full-suite failures (verification.test.ts, step-executor, etc.) are pre-existing and out of scope. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent da4f2c4 commit 1e29d11

2 files changed

Lines changed: 80 additions & 20 deletions

File tree

packages/sdk/src/__tests__/workflow-trajectory.test.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,32 @@ function readTrajectoryFile(dir: string): any {
3232
return JSON.parse(readFileSync(path.join(activeDir, jsonFiles[0]), 'utf-8'));
3333
}
3434

35-
function readCompletedTrajectoryFile(dir: string): any {
35+
function findCompletedTrajectoryJson(dir: string): string | null {
3636
const completedDir = path.join(dir, '.trajectories', 'completed');
3737
if (!existsSync(completedDir)) return null;
3838

39-
const files = readdirSync(completedDir);
40-
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
41-
if (jsonFiles.length === 0) return null;
39+
// Completed trajectories now live under completed/YYYY-MM/. Walk the
40+
// tree so tests don't have to know the exact bucket name.
41+
const stack: string[] = [completedDir];
42+
while (stack.length > 0) {
43+
const current = stack.pop() as string;
44+
const entries = readdirSync(current, { withFileTypes: true });
45+
for (const entry of entries) {
46+
const entryPath = path.join(current, entry.name);
47+
if (entry.isDirectory()) {
48+
stack.push(entryPath);
49+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
50+
return entryPath;
51+
}
52+
}
53+
}
54+
return null;
55+
}
4256

43-
return JSON.parse(readFileSync(path.join(completedDir, jsonFiles[0]), 'utf-8'));
57+
function readCompletedTrajectoryFile(dir: string): any {
58+
const jsonPath = findCompletedTrajectoryJson(dir);
59+
if (!jsonPath) return null;
60+
return JSON.parse(readFileSync(jsonPath, 'utf-8'));
4461
}
4562

4663
// ── Tests ────────────────────────────────────────────────────────────────────
@@ -135,6 +152,34 @@ describe('WorkflowTrajectory', () => {
135152
expect(completed).toBeTruthy();
136153
expect(completed.status).toBe('abandoned');
137154
});
155+
156+
it('should write completed files under completed/YYYY-MM/', async () => {
157+
const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
158+
await traj.start('my-workflow', 2);
159+
await traj.complete('All done', 0.95);
160+
161+
const jsonPath = findCompletedTrajectoryJson(tmpDir);
162+
expect(jsonPath).not.toBeNull();
163+
164+
// Relative path from .trajectories/completed must have exactly one
165+
// intermediate directory matching YYYY-MM.
166+
const completedRoot = path.join(tmpDir, '.trajectories', 'completed');
167+
const rel = path.relative(completedRoot, jsonPath as string);
168+
const segments = rel.split(path.sep);
169+
expect(segments).toHaveLength(2);
170+
expect(segments[0]).toMatch(/^\d{4}-\d{2}$/);
171+
expect(segments[1]).toMatch(/^traj_.*\.json$/);
172+
});
173+
174+
it('should populate canonical empty arrays on start', async () => {
175+
const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
176+
await traj.start('my-workflow', 2);
177+
178+
const data = readTrajectoryFile(tmpDir);
179+
expect(data.commits).toEqual([]);
180+
expect(data.filesChanged).toEqual([]);
181+
expect(data.tags).toEqual([]);
182+
});
138183
});
139184

140185
// ── Step events ────────────────────────────────────────────────────────
@@ -143,10 +188,7 @@ describe('WorkflowTrajectory', () => {
143188
it('should record step started', async () => {
144189
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
145190
await traj.start('wf', 2);
146-
await traj.stepStarted(
147-
{ name: 'build', agent: 'builder', task: 'Build it' },
148-
'builder-agent',
149-
);
191+
await traj.stepStarted({ name: 'build', agent: 'builder', task: 'Build it' }, 'builder-agent');
150192

151193
const data = readTrajectoryFile(tmpDir);
152194
expect(data.agents).toHaveLength(2); // orchestrator + builder-agent
@@ -157,11 +199,7 @@ describe('WorkflowTrajectory', () => {
157199
it('should record step completed', async () => {
158200
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
159201
await traj.start('wf', 1);
160-
await traj.stepCompleted(
161-
{ name: 'test', agent: 'tester', task: 'Run tests' },
162-
'All tests passing',
163-
1,
164-
);
202+
await traj.stepCompleted({ name: 'test', agent: 'tester', task: 'Run tests' }, 'All tests passing', 1);
165203

166204
const data = readTrajectoryFile(tmpDir);
167205
const events = data.chapters.flatMap((c: any) => c.events);
@@ -175,7 +213,7 @@ describe('WorkflowTrajectory', () => {
175213
{ name: 'deploy', agent: 'deployer', task: 'Deploy' },
176214
'Connection refused',
177215
1,
178-
3,
216+
3
179217
);
180218

181219
const data = readTrajectoryFile(tmpDir);
@@ -186,10 +224,7 @@ describe('WorkflowTrajectory', () => {
186224
it('should record step skipped', async () => {
187225
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
188226
await traj.start('wf', 2);
189-
await traj.stepSkipped(
190-
{ name: 'integration', agent: 'tester', task: 'Test' },
191-
'Upstream failed',
192-
);
227+
await traj.stepSkipped({ name: 'integration', agent: 'tester', task: 'Test' }, 'Upstream failed');
193228

194229
const data = readTrajectoryFile(tmpDir);
195230
const events = data.chapters.flatMap((c: any) => c.events);

packages/sdk/src/workflows/trajectory.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ interface TrajectoryFile {
6666
learnings?: string[];
6767
challenges?: string[];
6868
};
69+
/**
70+
* Canonical top-level fields the `agent-trajectories` schema declares
71+
* (as `.default([])` / `.optional()`). We populate them here so the
72+
* written files are canonical-complete and round-trip cleanly through
73+
* any stricter future reader.
74+
*/
75+
commits: string[];
76+
filesChanged: string[];
77+
tags: string[];
6978
}
7079

7180
// ── Step state for synthesis ─────────────────────────────────────────────────
@@ -191,6 +200,9 @@ export class WorkflowTrajectory {
191200
startedAt: new Date().toISOString(),
192201
agents: [{ name: 'orchestrator', role: 'workflow-runner', joinedAt: new Date().toISOString() }],
193202
chapters: [],
203+
commits: [],
204+
filesChanged: [],
205+
tags: [],
194206
};
195207

196208
// Open Planning chapter — record intent, not just mechanics
@@ -765,7 +777,20 @@ export class WorkflowTrajectory {
765777

766778
try {
767779
const activeDir = path.join(this.dataDir, 'active');
768-
const completedDir = path.join(this.dataDir, 'completed');
780+
// Match the canonical `agent-trajectories` layout: completed files
781+
// live under `completed/YYYY-MM/` based on completedAt (falling back
782+
// to startedAt). Before this change we wrote to the flat
783+
// `completed/` root, which worked but diverged from what
784+
// `FileStorage.save()` produces and forced the reader to grow a
785+
// legacy-layout fallback. Aligning the writer lets the reader shed
786+
// that branch over time.
787+
const bucketSource = this.trajectory.completedAt ?? this.trajectory.startedAt;
788+
const bucketDate = new Date(bucketSource);
789+
const monthBucket = `${bucketDate.getUTCFullYear()}-${String(bucketDate.getUTCMonth() + 1).padStart(
790+
2,
791+
'0'
792+
)}`;
793+
const completedDir = path.join(this.dataDir, 'completed', monthBucket);
769794
await mkdir(completedDir, { recursive: true });
770795

771796
const activePath = path.join(activeDir, `${this.trajectory.id}.json`);

0 commit comments

Comments
 (0)