- commit
- a95b34f
- parent
- e4bbe2e
- author
- im_wower
- date
- 2026-03-21 19:42:34 +0800 CST
feat: scaffold worker runner logging contracts
10 files changed,
+995,
-58
+125,
-0
1@@ -0,0 +1,125 @@
2+import type {
3+ LocalRunLogSession,
4+ LocalRunLogSummary,
5+ LocalRunPaths,
6+ RunMetadata,
7+ RunStateSnapshot,
8+ WorkerLifecycleLogEntry
9+} from "@baa-conductor/logging";
10+
11+export type WorkerKind = "codex" | "shell" | "git";
12+export type StepKind = WorkerKind | "planner" | "review" | "finalize";
13+export type StepExecutionOutcome = "prepared" | "completed" | "failed" | "blocked";
14+export type CheckpointMode = "disabled" | "reserved_for_t006";
15+export type StepArtifactKind = "log" | "metadata" | "state" | "directory" | "artifact";
16+
17+export interface StepExecutionRuntime {
18+ repoRoot: string;
19+ worktreePath: string;
20+ runsRootDir?: string;
21+ env?: Record<string, string>;
22+}
23+
24+export interface StepCheckpointConfig {
25+ mode: CheckpointMode;
26+ resumeFromSeq?: number;
27+ summaryHint?: string;
28+}
29+
30+export interface StepExecutionRequest {
31+ taskId: string;
32+ stepId: string;
33+ runId: string;
34+ attempt: number;
35+ stepName: string;
36+ stepKind: StepKind;
37+ workerKind: WorkerKind;
38+ timeoutSec: number;
39+ runtime: StepExecutionRuntime;
40+ createdAt?: string;
41+ promptSummary?: string;
42+ command?: string;
43+ checkpoint?: StepCheckpointConfig;
44+}
45+
46+export interface StepCheckpointState {
47+ mode: CheckpointMode;
48+ lastCheckpointSeq: number;
49+ nextCheckpointSeq: number;
50+ resumeFromSeq?: number;
51+ deferredFeatures: Array<"git_diff">;
52+ note: string;
53+}
54+
55+export interface StepArtifact {
56+ name: string;
57+ kind: StepArtifactKind;
58+ path: string;
59+ description: string;
60+}
61+
62+export interface SuggestedFollowup {
63+ stepName: string;
64+ stepKind: StepKind;
65+ reason: string;
66+}
67+
68+export interface WorkerExecutionOutcome {
69+ ok: boolean;
70+ outcome: StepExecutionOutcome;
71+ summary: string;
72+ needsHuman?: boolean;
73+ blocked?: boolean;
74+ stdout?: string[];
75+ stderr?: string[];
76+ artifacts?: StepArtifact[];
77+ suggestedFollowup?: SuggestedFollowup[];
78+ exitCode?: number;
79+}
80+
81+export interface WorkerExecutor {
82+ execute(run: PreparedStepRun): Promise<WorkerExecutionOutcome>;
83+}
84+
85+export interface PreparedStepRun {
86+ request: StepExecutionRequest;
87+ logPaths: LocalRunPaths;
88+ logSession: LocalRunLogSession;
89+ metadata: RunMetadata;
90+ state: RunStateSnapshot;
91+ checkpoint: StepCheckpointState;
92+}
93+
94+export interface StepExecutionMetrics {
95+ durationMs: number;
96+ lifecycleEventCount: number;
97+ stdoutChunkCount: number;
98+ stderrChunkCount: number;
99+ checkpointCount: number;
100+}
101+
102+export interface StepExecutionResult {
103+ ok: boolean;
104+ outcome: StepExecutionOutcome;
105+ summary: string;
106+ needsHuman: boolean;
107+ blocked: boolean;
108+ taskId: string;
109+ stepId: string;
110+ runId: string;
111+ attempt: number;
112+ workerKind: WorkerKind;
113+ startedAt: string;
114+ finishedAt: string;
115+ durationMs: number;
116+ metadata: RunMetadata;
117+ state: RunStateSnapshot;
118+ logPaths: LocalRunPaths;
119+ logSession: LocalRunLogSession;
120+ logSummary: LocalRunLogSummary;
121+ lifecycleEvents: WorkerLifecycleLogEntry[];
122+ checkpoint: StepCheckpointState;
123+ artifacts: StepArtifact[];
124+ suggestedFollowup: SuggestedFollowup[];
125+ metrics: StepExecutionMetrics;
126+}
+2,
-26
1@@ -1,26 +1,2 @@
2-export type WorkerKind = "codex" | "shell" | "git";
3-
4-export interface StepExecutionRequest {
5- taskId: string;
6- stepId: string;
7- stepName: string;
8- workerKind: WorkerKind;
9- logDir: string;
10-}
11-
12-export interface StepExecutionResult {
13- ok: boolean;
14- summary: string;
15- checkpointCount: number;
16- logsPath: string;
17-}
18-
19-export async function runStep(request: StepExecutionRequest): Promise<StepExecutionResult> {
20- return {
21- ok: false,
22- summary: `Step ${request.stepId} 仅有骨架,尚未接入真实 ${request.workerKind} worker。`,
23- checkpointCount: 0,
24- logsPath: request.logDir
25- };
26-}
27-
28+export * from "./contracts";
29+export * from "./runner";
+382,
-0
1@@ -0,0 +1,382 @@
2+import {
3+ appendStreamChunk,
4+ createLocalRunLogSession,
5+ createLocalRunPaths,
6+ createRunMetadata,
7+ createRunStateSnapshot,
8+ recordLifecycleEvent,
9+ summarizeLocalRunLogSession,
10+ updateRunState,
11+ type LogLevel,
12+ type RunStatePatch,
13+ type RunStatus,
14+ type WorkerLifecycleEventType
15+} from "@baa-conductor/logging";
16+import type {
17+ PreparedStepRun,
18+ StepArtifact,
19+ StepCheckpointConfig,
20+ StepCheckpointState,
21+ StepExecutionOutcome,
22+ StepExecutionRequest,
23+ StepExecutionResult,
24+ WorkerExecutionOutcome,
25+ WorkerExecutor
26+} from "./contracts";
27+
28+const DEFERRED_CHECKPOINT_FEATURES: Array<"git_diff"> = ["git_diff"];
29+
30+function resolveCheckpointConfig(config?: StepCheckpointConfig): StepCheckpointConfig {
31+ return config ?? {
32+ mode: "reserved_for_t006"
33+ };
34+}
35+
36+function createCheckpointState(config: StepCheckpointConfig): StepCheckpointState {
37+ const lastCheckpointSeq = config.resumeFromSeq ?? 0;
38+
39+ return {
40+ mode: config.mode,
41+ lastCheckpointSeq,
42+ nextCheckpointSeq: lastCheckpointSeq + 1,
43+ resumeFromSeq: config.resumeFromSeq,
44+ deferredFeatures: [...DEFERRED_CHECKPOINT_FEATURES],
45+ note:
46+ config.mode === "reserved_for_t006"
47+ ? "Checkpoint paths and sequence numbers are reserved for T-006. T-005 does not emit checkpoint content."
48+ : "Checkpointing is disabled for this step request."
49+ };
50+}
51+
52+function synchronizeRunState(run: PreparedStepRun, patch: RunStatePatch): void {
53+ run.state = updateRunState(run.state, {
54+ ...patch,
55+ lastEventSeq: run.logSession.nextSeq - 1,
56+ checkpointSeq: run.checkpoint.lastCheckpointSeq
57+ });
58+}
59+
60+function createDefaultArtifacts(run: PreparedStepRun): StepArtifact[] {
61+ return [
62+ {
63+ name: "meta.json",
64+ kind: "metadata",
65+ path: run.logPaths.metaPath,
66+ description: "Immutable run metadata for this step attempt."
67+ },
68+ {
69+ name: "state.json",
70+ kind: "state",
71+ path: run.logPaths.statePath,
72+ description: "Mutable run state snapshot for progress tracking and recovery."
73+ },
74+ {
75+ name: "worker.log",
76+ kind: "log",
77+ path: run.logPaths.workerLogPath,
78+ description: "Lifecycle events rendered into the worker log stream."
79+ },
80+ {
81+ name: "stdout.log",
82+ kind: "log",
83+ path: run.logPaths.stdoutLogPath,
84+ description: "Raw stdout stream produced by the worker process."
85+ },
86+ {
87+ name: "stderr.log",
88+ kind: "log",
89+ path: run.logPaths.stderrLogPath,
90+ description: "Raw stderr stream produced by the worker process."
91+ },
92+ {
93+ name: "checkpoints",
94+ kind: "directory",
95+ path: run.logPaths.checkpointsDir,
96+ description: "Reserved checkpoint directory for future T-006 persistence."
97+ },
98+ {
99+ name: "artifacts",
100+ kind: "directory",
101+ path: run.logPaths.artifactsDir,
102+ description: "Reserved artifact directory for worker outputs."
103+ }
104+ ];
105+}
106+
107+function mergeArtifacts(run: PreparedStepRun, extraArtifacts: StepArtifact[] = []): StepArtifact[] {
108+ const merged = new Map<string, StepArtifact>();
109+
110+ for (const artifact of createDefaultArtifacts(run)) {
111+ merged.set(artifact.path, artifact);
112+ }
113+
114+ for (const artifact of extraArtifacts) {
115+ merged.set(artifact.path, artifact);
116+ }
117+
118+ return [...merged.values()];
119+}
120+
121+function mapOutcomeToRunStatus(outcome: StepExecutionOutcome): RunStatus {
122+ switch (outcome) {
123+ case "prepared":
124+ return "prepared";
125+ case "completed":
126+ return "completed";
127+ case "failed":
128+ return "failed";
129+ case "blocked":
130+ return "blocked";
131+ }
132+}
133+
134+function mapOutcomeToTerminalEvent(outcome: StepExecutionOutcome): WorkerLifecycleEventType {
135+ switch (outcome) {
136+ case "prepared":
137+ return "step_prepared";
138+ case "completed":
139+ return "step_completed";
140+ case "failed":
141+ return "step_failed";
142+ case "blocked":
143+ return "step_blocked";
144+ }
145+}
146+
147+function mapOutcomeToLevel(execution: WorkerExecutionOutcome): LogLevel {
148+ if (execution.outcome === "failed") {
149+ return "error";
150+ }
151+
152+ if (execution.outcome === "blocked") {
153+ return "warn";
154+ }
155+
156+ return "info";
157+}
158+
159+function resolveExitCode(execution: WorkerExecutionOutcome): number {
160+ if (execution.exitCode !== undefined) {
161+ return execution.exitCode;
162+ }
163+
164+ return execution.ok ? 0 : 1;
165+}
166+
167+export function prepareStepRun(request: StepExecutionRequest): PreparedStepRun {
168+ const startedAt = request.createdAt ?? new Date().toISOString();
169+ const checkpointConfig = resolveCheckpointConfig(request.checkpoint);
170+ const checkpoint = createCheckpointState(checkpointConfig);
171+ const logPaths = createLocalRunPaths({
172+ repoRoot: request.runtime.repoRoot,
173+ taskId: request.taskId,
174+ runId: request.runId,
175+ runsRootDir: request.runtime.runsRootDir
176+ });
177+ const logSession = createLocalRunLogSession(logPaths, {
178+ taskId: request.taskId,
179+ stepId: request.stepId,
180+ runId: request.runId
181+ });
182+ const run: PreparedStepRun = {
183+ request,
184+ logPaths,
185+ logSession,
186+ metadata: createRunMetadata({
187+ taskId: request.taskId,
188+ stepId: request.stepId,
189+ stepName: request.stepName,
190+ runId: request.runId,
191+ workerKind: request.workerKind,
192+ attempt: request.attempt,
193+ repoRoot: request.runtime.repoRoot,
194+ worktreePath: request.runtime.worktreePath,
195+ createdAt: startedAt,
196+ checkpointMode: checkpoint.mode,
197+ promptSummary: request.promptSummary,
198+ command: request.command
199+ }),
200+ state: createRunStateSnapshot({
201+ attempt: request.attempt,
202+ startedAt,
203+ checkpointSeq: checkpoint.lastCheckpointSeq,
204+ summary: `Prepared local run layout for ${request.stepId}.`
205+ }),
206+ checkpoint
207+ };
208+
209+ recordLifecycleEvent(run.logSession, {
210+ type: "run_prepared",
211+ level: "info",
212+ createdAt: startedAt,
213+ message: `Prepared local run layout for ${request.stepId}.`,
214+ data: {
215+ stepName: request.stepName,
216+ stepKind: request.stepKind,
217+ workerKind: request.workerKind,
218+ attempt: request.attempt,
219+ timeoutSec: request.timeoutSec,
220+ runDir: run.logPaths.runDir,
221+ worktreePath: request.runtime.worktreePath
222+ }
223+ });
224+
225+ if (checkpoint.mode === "reserved_for_t006") {
226+ recordLifecycleEvent(run.logSession, {
227+ type: "checkpoint_slot_reserved",
228+ level: "info",
229+ message: `Reserved checkpoint sequence ${checkpoint.nextCheckpointSeq} for future T-006 integration.`,
230+ data: {
231+ checkpointsDir: run.logPaths.checkpointsDir,
232+ nextCheckpointSeq: checkpoint.nextCheckpointSeq,
233+ deferredFeatures: checkpoint.deferredFeatures
234+ }
235+ });
236+ }
237+
238+ synchronizeRunState(run, {
239+ updatedAt: startedAt
240+ });
241+
242+ return run;
243+}
244+
245+export function createPlaceholderWorkerExecutor(): WorkerExecutor {
246+ return {
247+ async execute(run: PreparedStepRun): Promise<WorkerExecutionOutcome> {
248+ return {
249+ ok: true,
250+ outcome: "prepared",
251+ summary: `Prepared local log streams and result envelope for ${run.request.stepId}; real ${run.request.workerKind} execution remains deferred.`,
252+ needsHuman: false,
253+ blocked: false,
254+ exitCode: 0
255+ };
256+ }
257+ };
258+}
259+
260+export async function runStep(
261+ request: StepExecutionRequest,
262+ executor: WorkerExecutor = createPlaceholderWorkerExecutor()
263+): Promise<StepExecutionResult> {
264+ const startedAt = request.createdAt ?? new Date().toISOString();
265+ const run = prepareStepRun({
266+ ...request,
267+ createdAt: startedAt
268+ });
269+ const executionStartedAt = new Date().toISOString();
270+
271+ recordLifecycleEvent(run.logSession, {
272+ type: "worker_started",
273+ level: "info",
274+ createdAt: executionStartedAt,
275+ message: `Worker runner opened execution scope for ${request.workerKind} step ${request.stepId}.`,
276+ data: {
277+ stepKind: request.stepKind,
278+ timeoutSec: request.timeoutSec,
279+ worktreePath: request.runtime.worktreePath
280+ }
281+ });
282+ synchronizeRunState(run, {
283+ status: "running",
284+ updatedAt: executionStartedAt
285+ });
286+
287+ const execution = await executor.execute(run);
288+ const blocked = execution.blocked ?? execution.outcome === "blocked";
289+ const needsHuman = execution.needsHuman ?? false;
290+ const exitCode = resolveExitCode(execution);
291+
292+ for (const line of execution.stdout ?? []) {
293+ appendStreamChunk(run.logSession, "stdout", {
294+ text: line
295+ });
296+ }
297+
298+ for (const line of execution.stderr ?? []) {
299+ appendStreamChunk(run.logSession, "stderr", {
300+ text: line
301+ });
302+ }
303+
304+ if (execution.outcome === "prepared") {
305+ recordLifecycleEvent(run.logSession, {
306+ type: "worker_execution_deferred",
307+ level: "info",
308+ message: `Real ${request.workerKind} execution is intentionally deferred in T-005.`,
309+ data: {
310+ deferredFeatures: run.checkpoint.deferredFeatures,
311+ nextCheckpointSeq: run.checkpoint.nextCheckpointSeq
312+ }
313+ });
314+ }
315+
316+ recordLifecycleEvent(run.logSession, {
317+ type: "worker_exited",
318+ level: exitCode === 0 ? "info" : "error",
319+ message: `Worker runner closed execution scope with outcome ${execution.outcome}.`,
320+ data: {
321+ exitCode,
322+ outcome: execution.outcome
323+ }
324+ });
325+
326+ const finishedAt = new Date().toISOString();
327+
328+ recordLifecycleEvent(run.logSession, {
329+ type: mapOutcomeToTerminalEvent(execution.outcome),
330+ level: mapOutcomeToLevel(execution),
331+ createdAt: finishedAt,
332+ message: execution.summary,
333+ data: {
334+ ok: execution.ok,
335+ blocked,
336+ needsHuman,
337+ exitCode
338+ }
339+ });
340+
341+ synchronizeRunState(run, {
342+ status: mapOutcomeToRunStatus(execution.outcome),
343+ updatedAt: finishedAt,
344+ finishedAt,
345+ summary: execution.summary,
346+ exitCode
347+ });
348+
349+ const logSummary = summarizeLocalRunLogSession(run.logSession);
350+ const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
351+
352+ return {
353+ ok: execution.ok,
354+ outcome: execution.outcome,
355+ summary: execution.summary,
356+ needsHuman,
357+ blocked,
358+ taskId: request.taskId,
359+ stepId: request.stepId,
360+ runId: request.runId,
361+ attempt: request.attempt,
362+ workerKind: request.workerKind,
363+ startedAt,
364+ finishedAt,
365+ durationMs,
366+ metadata: run.metadata,
367+ state: run.state,
368+ logPaths: run.logPaths,
369+ logSession: run.logSession,
370+ logSummary,
371+ lifecycleEvents: [...run.logSession.worker.entries],
372+ checkpoint: run.checkpoint,
373+ artifacts: mergeArtifacts(run, execution.artifacts),
374+ suggestedFollowup: execution.suggestedFollowup ?? [],
375+ metrics: {
376+ durationMs,
377+ lifecycleEventCount: logSummary.lifecycleEventCount,
378+ stdoutChunkCount: logSummary.stdoutChunkCount,
379+ stderrChunkCount: logSummary.stderrChunkCount,
380+ checkpointCount: 0
381+ }
382+ };
383+}
+7,
-4
1@@ -1,9 +1,12 @@
2 {
3 "extends": "../../tsconfig.base.json",
4 "compilerOptions": {
5- "rootDir": "src",
6- "outDir": "dist"
7+ "rootDir": "../..",
8+ "outDir": "dist",
9+ "baseUrl": "../..",
10+ "paths": {
11+ "@baa-conductor/logging": ["packages/logging/src/index.ts"]
12+ }
13 },
14- "include": ["src/**/*.ts"]
15+ "include": ["src/**/*.ts", "../../packages/logging/src/**/*.ts"]
16 }
17-
1@@ -1,7 +1,7 @@
2 ---
3 task_id: T-005
4 title: Worker Runner 与本地日志流
5-status: todo
6+status: review
7 branch: feat/T-005-worker-runner
8 repo: /Users/george/code/baa-conductor
9 base_ref: main
10@@ -10,7 +10,7 @@ depends_on:
11 write_scope:
12 - apps/worker-runner/**
13 - packages/logging/**
14-updated_at: 2026-03-21
15+updated_at: 2026-03-21T19:41:39+0800
16 ---
17
18 # T-005 Worker Runner 与本地日志流
19@@ -49,23 +49,39 @@ updated_at: 2026-03-21
20
21 ## files_changed
22
23-- 待填写
24+- `apps/worker-runner/src/index.ts`
25+- `apps/worker-runner/src/contracts.ts`
26+- `apps/worker-runner/src/runner.ts`
27+- `apps/worker-runner/tsconfig.json`
28+- `packages/logging/src/index.ts`
29+- `packages/logging/src/contracts.ts`
30+- `packages/logging/src/paths.ts`
31+- `packages/logging/src/session.ts`
32+- `packages/logging/src/state.ts`
33+- `coordination/tasks/T-005-worker-runner.md`
34
35 ## commands_run
36
37-- 待填写
38+- `npx -p typescript tsc --version`
39+- `npx -p typescript tsc --noEmit -p /Users/george/code/baa-conductor-t005/packages/logging/tsconfig.json`
40+- `npx -p typescript tsc --noEmit -p /Users/george/code/baa-conductor-t005/apps/worker-runner/tsconfig.json`
41+- `git -C /Users/george/code/baa-conductor-t005 diff --check`
42
43 ## result
44
45-- 待填写
46+- 建立了 `packages/logging` 的本地 run 路径约定、`meta/state` 数据结构、生命周期事件模型,以及 `worker.log/stdout.log/stderr.log` 的内存态抽象。
47+- 建立了 `apps/worker-runner` 的 step request/result、prepared run、默认占位 executor 与生命周期编排,明确区分 `prepared/completed/failed/blocked` 结果。
48+- 为 `T-006` 预留了 checkpoint 序号与目录接入点,但没有实现 checkpoint diff 或任何 checkpoint 内容落盘。
49
50 ## risks
51
52-- 待填写
53+- 当前实现只定义路径、事件和内存态会话;还没有真实文件写入,因此 `meta.json`、`state.json`、各类 log 仍未持久化。
54+- 当前 executor 仍是占位实现,没有接真实 Codex、Shell 或 Git worker,后续接入时还需要补真实进程生命周期与输出采集。
55
56 ## next_handoff
57
58-- 给 `T-006` 提供 checkpoint 接入点
59+- `T-006` 可以直接基于 `PreparedStepRun`、`StepCheckpointState` 和 `logPaths` 接入 checkpoint 目录写入与恢复逻辑。
60+- 后续 worker 执行接入时应复用当前 `StepExecutionResult` 和 logging session 结构,把真实 stdout/stderr/worker 事件落到同一套路径与结果模型。
61
62 ## notes
63
+188,
-0
1@@ -0,0 +1,188 @@
2+export const DEFAULT_RUNS_DIRECTORY_NAME = "runs";
3+export const DEFAULT_RUN_LAYOUT_VERSION = "local-run-v1";
4+
5+export type StructuredValue =
6+ | string
7+ | number
8+ | boolean
9+ | null
10+ | StructuredValue[]
11+ | { [key: string]: StructuredValue };
12+
13+export type StructuredData = Record<string, StructuredValue>;
14+
15+export type LogLevel = "debug" | "info" | "warn" | "error";
16+export type WorkerLogChannel = "worker" | "stdout" | "stderr";
17+export type StreamLogChannel = "stdout" | "stderr";
18+export type RunStatus = "prepared" | "running" | "completed" | "failed" | "blocked";
19+
20+export type WorkerLifecycleEventType =
21+ | "run_prepared"
22+ | "checkpoint_slot_reserved"
23+ | "worker_started"
24+ | "worker_execution_deferred"
25+ | "worker_exited"
26+ | "step_prepared"
27+ | "step_completed"
28+ | "step_failed"
29+ | "step_blocked";
30+
31+export interface LocalRunDirectoryRequest {
32+ repoRoot: string;
33+ taskId: string;
34+ runId: string;
35+ runsRootDir?: string;
36+}
37+
38+export interface LocalRunPaths {
39+ layoutVersion: string;
40+ repoRoot: string;
41+ runsRootDir: string;
42+ taskRunsDir: string;
43+ runDir: string;
44+ checkpointsDir: string;
45+ artifactsDir: string;
46+ metaPath: string;
47+ statePath: string;
48+ workerLogPath: string;
49+ stdoutLogPath: string;
50+ stderrLogPath: string;
51+}
52+
53+export interface LocalRunLogContext {
54+ taskId: string;
55+ stepId: string;
56+ runId: string;
57+}
58+
59+export interface LocalRunLogTarget {
60+ channel: WorkerLogChannel;
61+ filePath: string;
62+}
63+
64+export interface LocalRunLogTargets {
65+ worker: LocalRunLogTarget;
66+ stdout: LocalRunLogTarget;
67+ stderr: LocalRunLogTarget;
68+}
69+
70+export interface WorkerLifecycleEventInput {
71+ type: WorkerLifecycleEventType;
72+ level: LogLevel;
73+ message: string;
74+ createdAt?: string;
75+ data?: StructuredData;
76+}
77+
78+export interface WorkerLifecycleLogEntry extends LocalRunLogContext {
79+ seq: number;
80+ channel: "worker";
81+ eventId: string;
82+ type: WorkerLifecycleEventType;
83+ level: LogLevel;
84+ createdAt: string;
85+ message: string;
86+ data: StructuredData;
87+ renderedLine: string;
88+}
89+
90+export interface StreamChunkInput {
91+ text: string;
92+ createdAt?: string;
93+}
94+
95+export interface StreamChunkLogEntry extends LocalRunLogContext {
96+ seq: number;
97+ channel: StreamLogChannel;
98+ createdAt: string;
99+ text: string;
100+}
101+
102+export interface WorkerLifecycleLogStream {
103+ channel: "worker";
104+ filePath: string;
105+ entries: WorkerLifecycleLogEntry[];
106+}
107+
108+export interface StreamChunkLogStream {
109+ channel: StreamLogChannel;
110+ filePath: string;
111+ entries: StreamChunkLogEntry[];
112+}
113+
114+export interface LocalRunLogSession {
115+ context: LocalRunLogContext;
116+ paths: LocalRunPaths;
117+ nextSeq: number;
118+ worker: WorkerLifecycleLogStream;
119+ stdout: StreamChunkLogStream;
120+ stderr: StreamChunkLogStream;
121+}
122+
123+export interface LocalRunLogSummary {
124+ totalEntries: number;
125+ lastSeq: number;
126+ lifecycleEventCount: number;
127+ stdoutChunkCount: number;
128+ stderrChunkCount: number;
129+}
130+
131+export interface RunMetadata {
132+ layoutVersion: string;
133+ taskId: string;
134+ stepId: string;
135+ stepName: string;
136+ runId: string;
137+ workerKind: string;
138+ attempt: number;
139+ repoRoot: string;
140+ worktreePath: string;
141+ createdAt: string;
142+ checkpointMode: string;
143+ promptSummary?: string;
144+ command?: string;
145+}
146+
147+export interface RunMetadataInput {
148+ taskId: string;
149+ stepId: string;
150+ stepName: string;
151+ runId: string;
152+ workerKind: string;
153+ attempt: number;
154+ repoRoot: string;
155+ worktreePath: string;
156+ createdAt: string;
157+ checkpointMode: string;
158+ promptSummary?: string;
159+ command?: string;
160+}
161+
162+export interface RunStateSnapshot {
163+ status: RunStatus;
164+ attempt: number;
165+ startedAt: string;
166+ updatedAt: string;
167+ finishedAt?: string;
168+ lastEventSeq: number;
169+ checkpointSeq: number;
170+ summary?: string;
171+ exitCode?: number;
172+}
173+
174+export interface RunStateSnapshotInput {
175+ attempt: number;
176+ startedAt: string;
177+ checkpointSeq?: number;
178+ summary?: string;
179+}
180+
181+export interface RunStatePatch {
182+ status?: RunStatus;
183+ updatedAt?: string;
184+ finishedAt?: string;
185+ lastEventSeq?: number;
186+ checkpointSeq?: number;
187+ summary?: string;
188+ exitCode?: number;
189+}
+4,
-21
1@@ -1,21 +1,4 @@
2-export type LogLevel = "debug" | "info" | "warn" | "error";
3-
4-export interface RunEvent {
5- seq: number;
6- level: LogLevel;
7- message: string;
8- createdAt: string;
9-}
10-
11-export interface BufferedLogState {
12- lastSeq: number;
13- buffer: RunEvent[];
14-}
15-
16-export function createBufferedLogState(): BufferedLogState {
17- return {
18- lastSeq: 0,
19- buffer: []
20- };
21-}
22-
23+export * from "./contracts";
24+export * from "./paths";
25+export * from "./session";
26+export * from "./state";
+76,
-0
1@@ -0,0 +1,76 @@
2+import {
3+ DEFAULT_RUN_LAYOUT_VERSION,
4+ DEFAULT_RUNS_DIRECTORY_NAME,
5+ type LocalRunDirectoryRequest,
6+ type LocalRunLogTargets,
7+ type LocalRunPaths
8+} from "./contracts";
9+
10+function trimTrailingSlashes(value: string): string {
11+ const trimmed = value.replace(/\/+$/u, "");
12+
13+ return trimmed === "" ? "/" : trimmed;
14+}
15+
16+function sanitizePathSegment(value: string): string {
17+ const trimmed = value.trim().replace(/^\/+|\/+$/gu, "");
18+
19+ if (trimmed === "") {
20+ return "_";
21+ }
22+
23+ return trimmed.replace(/[\\/]+/gu, "_");
24+}
25+
26+function joinPath(basePath: string, segment: string): string {
27+ const normalizedBasePath = trimTrailingSlashes(basePath);
28+
29+ if (normalizedBasePath === "/") {
30+ return `/${segment}`;
31+ }
32+
33+ return `${normalizedBasePath}/${segment}`;
34+}
35+
36+export function createLocalRunPaths(request: LocalRunDirectoryRequest): LocalRunPaths {
37+ const runsRootDir =
38+ request.runsRootDir === undefined
39+ ? joinPath(request.repoRoot, DEFAULT_RUNS_DIRECTORY_NAME)
40+ : trimTrailingSlashes(request.runsRootDir);
41+ const taskRunsDir = joinPath(runsRootDir, sanitizePathSegment(request.taskId));
42+ const runDir = joinPath(taskRunsDir, sanitizePathSegment(request.runId));
43+ const checkpointsDir = joinPath(runDir, "checkpoints");
44+ const artifactsDir = joinPath(runDir, "artifacts");
45+
46+ return {
47+ layoutVersion: DEFAULT_RUN_LAYOUT_VERSION,
48+ repoRoot: trimTrailingSlashes(request.repoRoot),
49+ runsRootDir,
50+ taskRunsDir,
51+ runDir,
52+ checkpointsDir,
53+ artifactsDir,
54+ metaPath: joinPath(runDir, "meta.json"),
55+ statePath: joinPath(runDir, "state.json"),
56+ workerLogPath: joinPath(runDir, "worker.log"),
57+ stdoutLogPath: joinPath(runDir, "stdout.log"),
58+ stderrLogPath: joinPath(runDir, "stderr.log")
59+ };
60+}
61+
62+export function createLocalRunLogTargets(paths: LocalRunPaths): LocalRunLogTargets {
63+ return {
64+ worker: {
65+ channel: "worker",
66+ filePath: paths.workerLogPath
67+ },
68+ stdout: {
69+ channel: "stdout",
70+ filePath: paths.stdoutLogPath
71+ },
72+ stderr: {
73+ channel: "stderr",
74+ filePath: paths.stderrLogPath
75+ }
76+ };
77+}
+134,
-0
1@@ -0,0 +1,134 @@
2+import {
3+ type LocalRunLogContext,
4+ type LocalRunLogSession,
5+ type LocalRunLogSummary,
6+ type LocalRunPaths,
7+ type StreamChunkInput,
8+ type StreamChunkLogEntry,
9+ type StreamChunkLogStream,
10+ type StreamLogChannel,
11+ type WorkerLifecycleEventInput,
12+ type WorkerLifecycleLogEntry
13+} from "./contracts";
14+import { createLocalRunLogTargets } from "./paths";
15+
16+function allocateSeq(session: LocalRunLogSession): number {
17+ const seq = session.nextSeq;
18+ session.nextSeq += 1;
19+
20+ return seq;
21+}
22+
23+function getStream(session: LocalRunLogSession, channel: StreamLogChannel): StreamChunkLogStream {
24+ switch (channel) {
25+ case "stdout":
26+ return session.stdout;
27+ case "stderr":
28+ return session.stderr;
29+ }
30+}
31+
32+export function serializeLifecycleEvent(
33+ entry: Omit<WorkerLifecycleLogEntry, "renderedLine">
34+): string {
35+ return JSON.stringify({
36+ seq: entry.seq,
37+ eventId: entry.eventId,
38+ taskId: entry.taskId,
39+ stepId: entry.stepId,
40+ runId: entry.runId,
41+ type: entry.type,
42+ level: entry.level,
43+ createdAt: entry.createdAt,
44+ message: entry.message,
45+ data: entry.data
46+ });
47+}
48+
49+export function createLocalRunLogSession(
50+ paths: LocalRunPaths,
51+ context: LocalRunLogContext
52+): LocalRunLogSession {
53+ const targets = createLocalRunLogTargets(paths);
54+
55+ return {
56+ context,
57+ paths,
58+ nextSeq: 1,
59+ worker: {
60+ channel: "worker",
61+ filePath: targets.worker.filePath,
62+ entries: []
63+ },
64+ stdout: {
65+ channel: "stdout",
66+ filePath: targets.stdout.filePath,
67+ entries: []
68+ },
69+ stderr: {
70+ channel: "stderr",
71+ filePath: targets.stderr.filePath,
72+ entries: []
73+ }
74+ };
75+}
76+
77+export function recordLifecycleEvent(
78+ session: LocalRunLogSession,
79+ input: WorkerLifecycleEventInput
80+): WorkerLifecycleLogEntry {
81+ const seq = allocateSeq(session);
82+ const createdAt = input.createdAt ?? new Date().toISOString();
83+ const baseEntry = {
84+ ...session.context,
85+ seq,
86+ channel: "worker" as const,
87+ eventId: `${session.context.runId}:event:${String(seq).padStart(4, "0")}`,
88+ type: input.type,
89+ level: input.level,
90+ createdAt,
91+ message: input.message,
92+ data: input.data ?? {}
93+ };
94+ const entry: WorkerLifecycleLogEntry = {
95+ ...baseEntry,
96+ renderedLine: serializeLifecycleEvent(baseEntry)
97+ };
98+
99+ session.worker.entries.push(entry);
100+
101+ return entry;
102+}
103+
104+export function appendStreamChunk(
105+ session: LocalRunLogSession,
106+ channel: StreamLogChannel,
107+ input: StreamChunkInput
108+): StreamChunkLogEntry {
109+ const seq = allocateSeq(session);
110+ const entry: StreamChunkLogEntry = {
111+ ...session.context,
112+ seq,
113+ channel,
114+ createdAt: input.createdAt ?? new Date().toISOString(),
115+ text: input.text
116+ };
117+
118+ getStream(session, channel).entries.push(entry);
119+
120+ return entry;
121+}
122+
123+export function summarizeLocalRunLogSession(session: LocalRunLogSession): LocalRunLogSummary {
124+ const lifecycleEventCount = session.worker.entries.length;
125+ const stdoutChunkCount = session.stdout.entries.length;
126+ const stderrChunkCount = session.stderr.entries.length;
127+
128+ return {
129+ totalEntries: lifecycleEventCount + stdoutChunkCount + stderrChunkCount,
130+ lastSeq: session.nextSeq - 1,
131+ lifecycleEventCount,
132+ stdoutChunkCount,
133+ stderrChunkCount
134+ };
135+}
+54,
-0
1@@ -0,0 +1,54 @@
2+import {
3+ DEFAULT_RUN_LAYOUT_VERSION,
4+ type RunMetadata,
5+ type RunMetadataInput,
6+ type RunStatePatch,
7+ type RunStateSnapshot,
8+ type RunStateSnapshotInput
9+} from "./contracts";
10+
11+export function createRunMetadata(input: RunMetadataInput): RunMetadata {
12+ return {
13+ layoutVersion: DEFAULT_RUN_LAYOUT_VERSION,
14+ taskId: input.taskId,
15+ stepId: input.stepId,
16+ stepName: input.stepName,
17+ runId: input.runId,
18+ workerKind: input.workerKind,
19+ attempt: input.attempt,
20+ repoRoot: input.repoRoot,
21+ worktreePath: input.worktreePath,
22+ createdAt: input.createdAt,
23+ checkpointMode: input.checkpointMode,
24+ promptSummary: input.promptSummary,
25+ command: input.command
26+ };
27+}
28+
29+export function createRunStateSnapshot(input: RunStateSnapshotInput): RunStateSnapshot {
30+ return {
31+ status: "prepared",
32+ attempt: input.attempt,
33+ startedAt: input.startedAt,
34+ updatedAt: input.startedAt,
35+ lastEventSeq: 0,
36+ checkpointSeq: input.checkpointSeq ?? 0,
37+ summary: input.summary
38+ };
39+}
40+
41+export function updateRunState(
42+ snapshot: RunStateSnapshot,
43+ patch: RunStatePatch
44+): RunStateSnapshot {
45+ return {
46+ ...snapshot,
47+ status: patch.status ?? snapshot.status,
48+ updatedAt: patch.updatedAt ?? snapshot.updatedAt,
49+ finishedAt: patch.finishedAt ?? snapshot.finishedAt,
50+ lastEventSeq: patch.lastEventSeq ?? snapshot.lastEventSeq,
51+ checkpointSeq: patch.checkpointSeq ?? snapshot.checkpointSeq,
52+ summary: patch.summary ?? snapshot.summary,
53+ exitCode: patch.exitCode ?? snapshot.exitCode
54+ };
55+}