@grackle-ai/server 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ import type { SessionRow } from "./schema.js";
2
+ /** Result of computing a task's effective status from its session history. */
3
+ export interface TaskStatusResult {
4
+ /** The computed effective status string (e.g. "in_progress", "review", "pending"). */
5
+ status: string;
6
+ /** The ID of the most recent session (by startedAt), or empty string if none. */
7
+ latestSessionId: string;
8
+ }
9
+ /**
10
+ * Compute the effective task status from the stored DB status and the task's
11
+ * session history. Pure function — no DB access, no side effects.
12
+ *
13
+ * Rules:
14
+ * 1. Human-authoritative statuses ("done") are sticky — always returned as-is.
15
+ * 2. No sessions → return storedStatus unchanged.
16
+ * 3. Any active session (pending/running/waiting_input) → prefer waiting_input > in_progress.
17
+ * 4. Latest terminal session determines status:
18
+ * - completed → "review"
19
+ * - failed → "failed"
20
+ * - killed → "pending" (retryable)
21
+ *
22
+ * @param storedStatus - The task's status as stored in the DB.
23
+ * @param sessions - All sessions for this task, in any order.
24
+ * @returns Computed status and the ID of the latest session.
25
+ */
26
+ export declare function computeTaskStatus(storedStatus: string, sessions: Pick<SessionRow, "id" | "status" | "startedAt">[]): TaskStatusResult;
27
+ //# sourceMappingURL=compute-task-status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compute-task-status.d.ts","sourceRoot":"","sources":["../src/compute-task-status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,eAAe,EAAE,MAAM,CAAC;CACzB;AAsBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,GAAG,QAAQ,GAAG,WAAW,CAAC,EAAE,GAC1D,gBAAgB,CAsDlB"}
@@ -0,0 +1,93 @@
1
+ /** Session statuses that indicate the session is actively running. */
2
+ const ACTIVE_SESSION_STATUSES = new Set([
3
+ "pending",
4
+ "running",
5
+ "waiting_input",
6
+ ]);
7
+ /**
8
+ * Human-authoritative task statuses. These are sticky — once a human sets
9
+ * these statuses, session state does not override them.
10
+ *
11
+ * Note: "assigned" is intentionally NOT included. While it is set by humans,
12
+ * it must yield to active session status (e.g. a running session should show
13
+ * "in_progress", not "assigned"). The "assigned" status is only meaningful
14
+ * when there are no sessions.
15
+ */
16
+ const HUMAN_AUTHORITATIVE_STATUSES = new Set([
17
+ "done",
18
+ ]);
19
+ /**
20
+ * Compute the effective task status from the stored DB status and the task's
21
+ * session history. Pure function — no DB access, no side effects.
22
+ *
23
+ * Rules:
24
+ * 1. Human-authoritative statuses ("done") are sticky — always returned as-is.
25
+ * 2. No sessions → return storedStatus unchanged.
26
+ * 3. Any active session (pending/running/waiting_input) → prefer waiting_input > in_progress.
27
+ * 4. Latest terminal session determines status:
28
+ * - completed → "review"
29
+ * - failed → "failed"
30
+ * - killed → "pending" (retryable)
31
+ *
32
+ * @param storedStatus - The task's status as stored in the DB.
33
+ * @param sessions - All sessions for this task, in any order.
34
+ * @returns Computed status and the ID of the latest session.
35
+ */
36
+ export function computeTaskStatus(storedStatus, sessions) {
37
+ // Human-authoritative statuses are always sticky
38
+ if (HUMAN_AUTHORITATIVE_STATUSES.has(storedStatus)) {
39
+ const latestSessionId = sessions.length > 0
40
+ ? getLatestSession(sessions).id
41
+ : "";
42
+ return { status: storedStatus, latestSessionId };
43
+ }
44
+ // No sessions → return stored status, but clamp transient statuses back to
45
+ // "pending" since they should not persist without an active session (e.g.
46
+ // stale rows after migration).
47
+ if (sessions.length === 0) {
48
+ const TRANSIENT_STATUSES = new Set(["in_progress", "waiting_input"]);
49
+ const status = TRANSIENT_STATUSES.has(storedStatus) ? "pending" : storedStatus;
50
+ return { status, latestSessionId: "" };
51
+ }
52
+ // Check for any active sessions
53
+ const activeSessions = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status));
54
+ if (activeSessions.length > 0) {
55
+ // Prefer waiting_input over in_progress if any session is waiting
56
+ const hasWaitingInput = activeSessions.some((s) => s.status === "waiting_input");
57
+ return {
58
+ status: hasWaitingInput ? "waiting_input" : "in_progress",
59
+ latestSessionId: getLatestSession(sessions).id,
60
+ };
61
+ }
62
+ // All sessions are terminal — use the latest one to determine status
63
+ const latest = getLatestSession(sessions);
64
+ let status;
65
+ switch (latest.status) {
66
+ case "completed":
67
+ status = "review";
68
+ break;
69
+ case "failed":
70
+ status = "failed";
71
+ break;
72
+ case "killed":
73
+ status = "pending";
74
+ break;
75
+ default:
76
+ status = storedStatus;
77
+ break;
78
+ }
79
+ return { status, latestSessionId: latest.id };
80
+ }
81
+ /** Get the most recent session by startedAt (descending), breaking ties by ID. */
82
+ function getLatestSession(sessions) {
83
+ return sessions.reduce((latest, current) => {
84
+ if (current.startedAt > latest.startedAt) {
85
+ return current;
86
+ }
87
+ if (current.startedAt === latest.startedAt && current.id > latest.id) {
88
+ return current;
89
+ }
90
+ return latest;
91
+ });
92
+ }
93
+ //# sourceMappingURL=compute-task-status.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compute-task-status.js","sourceRoot":"","sources":["../src/compute-task-status.ts"],"names":[],"mappings":"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"}
package/dist/db.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAMtC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AA4CtC,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,IAAI,CA8NnC;AAKD,yDAAyD;AACzD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,QAAA,MAAM,EAAE,EAAE,qBAAqB,CAAC,OAAO,MAAM,CAAC,GAAG;IAC/C,OAAO,EAAE,YAAY,CAAC,OAAO,QAAQ,CAAC,CAAC;CACV,CAAC;AAEhC,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAMtC,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AA4CtC,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,IAAI,CAiRnC;AAKD,yDAAyD;AACzD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,QAAA,MAAM,EAAE,EAAE,qBAAqB,CAAC,OAAO,MAAM,CAAC,GAAG;IAC/C,OAAO,EAAE,YAAY,CAAC,OAAO,QAAQ,CAAC,CAAC;CACV,CAAC;AAEhC,eAAe,EAAE,CAAC"}
package/dist/db.js CHANGED
@@ -69,11 +69,12 @@ export function initDatabase() {
69
69
  status TEXT NOT NULL DEFAULT 'pending',
70
70
  log_path TEXT,
71
71
  turns INTEGER NOT NULL DEFAULT 0,
72
- started_at TEXT NOT NULL DEFAULT (datetime('now')),
72
+ started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
73
73
  suspended_at TEXT,
74
74
  ended_at TEXT,
75
75
  error TEXT,
76
- task_id TEXT NOT NULL DEFAULT ''
76
+ task_id TEXT NOT NULL DEFAULT '',
77
+ persona_id TEXT NOT NULL DEFAULT ''
77
78
  );
78
79
 
79
80
  CREATE TABLE IF NOT EXISTS tokens (
@@ -101,8 +102,6 @@ export function initDatabase() {
101
102
  description TEXT NOT NULL DEFAULT '',
102
103
  status TEXT NOT NULL DEFAULT 'pending',
103
104
  branch TEXT NOT NULL DEFAULT '',
104
- env_id TEXT NOT NULL DEFAULT '',
105
- session_id TEXT NOT NULL DEFAULT '',
106
105
  depends_on TEXT NOT NULL DEFAULT '[]',
107
106
  assigned_at TEXT,
108
107
  started_at TEXT,
@@ -113,8 +112,7 @@ export function initDatabase() {
113
112
  sort_order INTEGER NOT NULL DEFAULT 0,
114
113
  parent_task_id TEXT NOT NULL DEFAULT '',
115
114
  depth INTEGER NOT NULL DEFAULT 0,
116
- can_decompose INTEGER NOT NULL DEFAULT 0,
117
- persona_id TEXT NOT NULL DEFAULT ''
115
+ can_decompose INTEGER NOT NULL DEFAULT 0
118
116
  );
119
117
 
120
118
  CREATE TABLE IF NOT EXISTS findings (
@@ -171,8 +169,6 @@ export function initDatabase() {
171
169
  UPDATE tasks SET description = '' WHERE description IS NULL;
172
170
  UPDATE tasks SET status = 'pending' WHERE status IS NULL;
173
171
  UPDATE tasks SET branch = '' WHERE branch IS NULL;
174
- UPDATE tasks SET env_id = '' WHERE env_id IS NULL;
175
- UPDATE tasks SET session_id = '' WHERE session_id IS NULL;
176
172
  UPDATE tasks SET depends_on = '[]' WHERE depends_on IS NULL;
177
173
  UPDATE tasks SET review_notes = '' WHERE review_notes IS NULL;
178
174
  UPDATE tasks SET created_at = datetime('now') WHERE created_at IS NULL;
@@ -238,14 +234,66 @@ export function initDatabase() {
238
234
  /* column already exists */
239
235
  }
240
236
  // Migration: backfill task_id on existing sessions from tasks.session_id.
241
- // Use LIMIT 1 to guard against multiple tasks pointing at the same session.
237
+ // Guard with try/catch since session_id column may have been dropped already.
238
+ try {
239
+ sqlite.exec(`
240
+ UPDATE sessions SET task_id = (
241
+ SELECT id FROM tasks WHERE tasks.session_id = sessions.id LIMIT 1
242
+ ) WHERE task_id = '' AND EXISTS (
243
+ SELECT 1 FROM tasks WHERE tasks.session_id = sessions.id
244
+ )
245
+ `);
246
+ }
247
+ catch {
248
+ /* tasks.session_id column already dropped */
249
+ }
250
+ // Migration: add persona_id column to sessions if missing
251
+ try {
252
+ sqlite.exec("ALTER TABLE sessions ADD COLUMN persona_id TEXT NOT NULL DEFAULT ''");
253
+ }
254
+ catch {
255
+ /* column already exists */
256
+ }
257
+ // Migration: copy persona_id from tasks to sessions before dropping
258
+ try {
259
+ sqlite.exec(`
260
+ UPDATE sessions SET persona_id = (
261
+ SELECT persona_id FROM tasks WHERE tasks.session_id = sessions.id LIMIT 1
262
+ ) WHERE persona_id = '' AND task_id != ''
263
+ `);
264
+ }
265
+ catch {
266
+ /* tasks.session_id or tasks.persona_id column may not exist */
267
+ }
268
+ // Migration: drop columns that moved off the tasks table
269
+ try {
270
+ sqlite.exec("ALTER TABLE tasks DROP COLUMN session_id");
271
+ }
272
+ catch {
273
+ /* column already dropped or never existed */
274
+ }
275
+ try {
276
+ sqlite.exec("ALTER TABLE tasks DROP COLUMN env_id");
277
+ }
278
+ catch {
279
+ /* column already dropped or never existed */
280
+ }
281
+ try {
282
+ sqlite.exec("ALTER TABLE tasks DROP COLUMN persona_id");
283
+ }
284
+ catch {
285
+ /* column already dropped or never existed */
286
+ }
287
+ // Migration: normalize existing started_at values from SQLite datetime('now')
288
+ // format (YYYY-MM-DD HH:MM:SS) to ISO 8601 (YYYY-MM-DDTHH:MM:SS.000Z) so
289
+ // ordering is consistent with newly inserted rows.
242
290
  sqlite.exec(`
243
- UPDATE sessions SET task_id = (
244
- SELECT id FROM tasks WHERE tasks.session_id = sessions.id LIMIT 1
245
- ) WHERE task_id = '' AND EXISTS (
246
- SELECT 1 FROM tasks WHERE tasks.session_id = sessions.id
247
- )
291
+ UPDATE sessions
292
+ SET started_at = replace(started_at, ' ', 'T') || '.000Z'
293
+ WHERE started_at NOT LIKE '%T%'
248
294
  `);
295
+ // Index for efficient session-by-task lookups
296
+ sqlite.exec("CREATE INDEX IF NOT EXISTS idx_sessions_task_id ON sessions(task_id)");
249
297
  }
250
298
  // Run init immediately for backwards compatibility — stores import db at module load
251
299
  initDatabase();
package/dist/db.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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"}
@@ -1,19 +1,33 @@
1
1
  import { powerline } from "@grackle-ai/common";
2
+ import type { ProcessorContext } from "./processor-registry.js";
2
3
  /** Options for processing an agent event stream. */
3
4
  export interface EventStreamOptions {
4
5
  sessionId: string;
5
6
  logPath: string;
6
7
  projectId?: string;
7
8
  taskId?: string;
8
- onComplete?: () => void;
9
9
  onError?: (error: unknown) => void;
10
10
  }
11
+ /**
12
+ * Process a finding event, storing it in the finding store and broadcasting.
13
+ * Shared between live event processing and replay on late-bind.
14
+ */
15
+ export declare function processFindingEvent(ctx: ProcessorContext, content: string, sessionId: string): void;
16
+ /**
17
+ * Process a subtask creation event, creating a child task and broadcasting.
18
+ * Shared between live event processing and replay on late-bind.
19
+ */
20
+ export declare function processSubtaskEvent(ctx: ProcessorContext, content: string, subtaskLocalIdMap: Map<string, string>): void;
11
21
  /**
12
22
  * Process an async iterable of agent events from a PowerLine spawn or resume stream.
13
23
  * Handles event transformation, logging, finding interception, status updates, and cleanup.
14
24
  *
15
25
  * This function is fire-and-forget: it runs in the background and does not throw.
16
26
  * Callers should use `onComplete` and `onError` callbacks for post-processing.
27
+ *
28
+ * Supports late-binding: if a task is associated with the session after the stream starts,
29
+ * the processor registry notifies this function via a bind listener, and pre-association
30
+ * events are replayed from the session log.
17
31
  */
18
32
  export declare function processEventStream(events: AsyncIterable<powerline.AgentEvent>, options: EventStreamOptions): void;
19
33
  //# sourceMappingURL=event-processor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"event-processor.d.ts","sourceRoot":"","sources":["../src/event-processor.ts"],"names":[],"mappings":"AACA,OAAO,EAAW,SAAS,EAAmB,MAAM,oBAAoB,CAAC;AAiBzE,oDAAoD;AACpD,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,aAAa,CAAC,SAAS,CAAC,UAAU,CAAC,EAC3C,OAAO,EAAE,kBAAkB,GAC1B,IAAI,CAsMN"}
1
+ {"version":3,"file":"event-processor.d.ts","sourceRoot":"","sources":["../src/event-processor.ts"],"names":[],"mappings":"AACA,OAAO,EAAW,SAAS,EAAmB,MAAM,oBAAoB,CAAC;AAczE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAKhE,oDAAoD;AACpD,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAChB,IAAI,CAiBN;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GACrC,IAAI,CAiGN;AAsCD;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,aAAa,CAAC,SAAS,CAAC,UAAU,CAAC,EAC3C,OAAO,EAAE,kBAAkB,GAC1B,IAAI,CAiHN"}
@@ -7,6 +7,7 @@ import * as logWriter from "./log-writer.js";
7
7
  import * as findingStore from "./finding-store.js";
8
8
  import * as taskStore from "./task-store.js";
9
9
  import * as projectStore from "./project-store.js";
10
+ import * as processorRegistry from "./processor-registry.js";
10
11
  import { slugify } from "./utils/slugify.js";
11
12
  import { writeTranscript } from "./transcript.js";
12
13
  import { broadcast } from "./ws-broadcast.js";
@@ -14,19 +15,162 @@ import { safeParseJsonArray } from "./json-helpers.js";
14
15
  import { logger } from "./logger.js";
15
16
  /** Terminal session statuses that indicate the session has already ended. */
16
17
  const TERMINAL_STATUSES = ["completed", "failed", "killed"];
18
+ /**
19
+ * Process a finding event, storing it in the finding store and broadcasting.
20
+ * Shared between live event processing and replay on late-bind.
21
+ */
22
+ export function processFindingEvent(ctx, content, sessionId) {
23
+ if (!ctx.projectId) {
24
+ return;
25
+ }
26
+ try {
27
+ const data = JSON.parse(content);
28
+ const findingId = uuid();
29
+ findingStore.postFinding(findingId, ctx.projectId, ctx.taskId || "", sessionId, data.category || "general", data.title || "Untitled", data.content || "", data.tags || []);
30
+ broadcast({ type: "finding_posted", payload: { projectId: ctx.projectId, findingId } });
31
+ logger.info({ findingId, projectId: ctx.projectId, title: data.title }, "Finding stored");
32
+ }
33
+ catch (err) {
34
+ logger.error({ err, projectId: ctx.projectId, taskId: ctx.taskId }, "Failed to store finding");
35
+ }
36
+ }
37
+ /**
38
+ * Process a subtask creation event, creating a child task and broadcasting.
39
+ * Shared between live event processing and replay on late-bind.
40
+ */
41
+ export function processSubtaskEvent(ctx, content, subtaskLocalIdMap) {
42
+ if (!ctx.taskId) {
43
+ return;
44
+ }
45
+ try {
46
+ const data = JSON.parse(content);
47
+ const parentTask = taskStore.getTask(ctx.taskId);
48
+ if (!parentTask) {
49
+ logger.warn({ taskId: ctx.taskId }, "Subtask creation failed: parent task not found");
50
+ return;
51
+ }
52
+ if (!parentTask.canDecompose) {
53
+ logger.warn({ taskId: ctx.taskId }, "Subtask creation failed: parent task cannot decompose");
54
+ return;
55
+ }
56
+ const project = projectStore.getProject(parentTask.projectId);
57
+ if (!project) {
58
+ logger.warn({ projectId: parentTask.projectId }, "Subtask creation failed: project not found");
59
+ return;
60
+ }
61
+ // Validate required fields
62
+ const title = typeof data.title === "string" ? data.title.trim() : "";
63
+ const description = typeof data.description === "string" ? data.description.trim() : "";
64
+ if (!title || !description) {
65
+ logger.warn({ taskId: ctx.taskId, rawTitle: data.title, rawDescription: data.description }, "Subtask creation failed: invalid title or description");
66
+ return;
67
+ }
68
+ // Normalize and validate depends_on, local_id, and can_decompose
69
+ const dependsOn = Array.isArray(data.depends_on)
70
+ ? data.depends_on.filter((d) => typeof d === "string").map(d => d.trim()).filter(Boolean)
71
+ : [];
72
+ const localId = typeof data.local_id === "string" ? data.local_id.trim() : "";
73
+ const canDecompose = typeof data.can_decompose === "boolean" ? data.can_decompose : false;
74
+ // Resolve depends_on local IDs to real task IDs
75
+ const resolvedDeps = [];
76
+ for (const localDep of dependsOn) {
77
+ const realId = subtaskLocalIdMap.get(localDep);
78
+ if (realId) {
79
+ resolvedDeps.push(realId);
80
+ }
81
+ else {
82
+ logger.warn({ localDep, taskId: ctx.taskId }, "Subtask dependency local_id not found, skipping");
83
+ }
84
+ }
85
+ const subtaskId = uuid().slice(0, 8);
86
+ taskStore.createTask(subtaskId, parentTask.projectId, title, description, resolvedDeps, slugify(project.name), ctx.taskId, canDecompose);
87
+ // Record the local_id → real ID mapping, detecting duplicates
88
+ if (localId) {
89
+ if (subtaskLocalIdMap.has(localId)) {
90
+ logger.warn({
91
+ localId,
92
+ existingSubtaskId: subtaskLocalIdMap.get(localId),
93
+ newSubtaskId: subtaskId,
94
+ parentTaskId: ctx.taskId,
95
+ }, "Duplicate subtask local_id encountered; keeping existing mapping");
96
+ }
97
+ else {
98
+ subtaskLocalIdMap.set(localId, subtaskId);
99
+ }
100
+ }
101
+ const row = taskStore.getTask(subtaskId);
102
+ broadcast({
103
+ type: "task_created",
104
+ payload: { task: row ? { ...row, dependsOn: safeParseJsonArray(row.dependsOn) } : null },
105
+ });
106
+ logger.info({ subtaskId, parentTaskId: ctx.taskId, title }, "Subtask created");
107
+ }
108
+ catch (err) {
109
+ logger.error({ err, taskId: ctx.taskId }, "Failed to create subtask");
110
+ }
111
+ }
112
+ /**
113
+ * Replay pre-association events from the session log through finding/subtask interceptors.
114
+ * Called when a session is late-bound to a task. Does not re-publish to streamHub.
115
+ *
116
+ * Note: Uses synchronous readFileSync while the log is written via a buffered WriteStream.
117
+ * Events written very recently may still be in the write buffer and not yet flushed to disk.
118
+ * In practice this is negligible since replay targets events written before the current
119
+ * iteration of the for-await loop, which are already flushed by the time lateBind is called.
120
+ */
121
+ function replayLoggedEvents(ctx, subtaskLocalIdMap) {
122
+ try {
123
+ const entries = logWriter.readLog(ctx.logPath);
124
+ let findingsReplayed = 0;
125
+ let subtasksReplayed = 0;
126
+ for (const entry of entries) {
127
+ if (entry.type === "finding") {
128
+ processFindingEvent(ctx, entry.content, entry.session_id);
129
+ findingsReplayed++;
130
+ }
131
+ else if (entry.type === "subtask_create") {
132
+ processSubtaskEvent(ctx, entry.content, subtaskLocalIdMap);
133
+ subtasksReplayed++;
134
+ }
135
+ }
136
+ if (findingsReplayed > 0 || subtasksReplayed > 0) {
137
+ logger.info({ sessionId: ctx.sessionId, taskId: ctx.taskId, findingsReplayed, subtasksReplayed }, "Replayed pre-association events from session log");
138
+ }
139
+ }
140
+ catch (err) {
141
+ logger.error({ err, sessionId: ctx.sessionId }, "Failed to replay logged events");
142
+ }
143
+ }
17
144
  /**
18
145
  * Process an async iterable of agent events from a PowerLine spawn or resume stream.
19
146
  * Handles event transformation, logging, finding interception, status updates, and cleanup.
20
147
  *
21
148
  * This function is fire-and-forget: it runs in the background and does not throw.
22
149
  * Callers should use `onComplete` and `onError` callbacks for post-processing.
150
+ *
151
+ * Supports late-binding: if a task is associated with the session after the stream starts,
152
+ * the processor registry notifies this function via a bind listener, and pre-association
153
+ * events are replayed from the session log.
23
154
  */
24
155
  export function processEventStream(events, options) {
25
- const { sessionId, logPath, projectId, taskId, onComplete, onError } = options;
156
+ const { sessionId, logPath, onError } = options;
157
+ // Create a mutable context that can be updated via the processor registry
158
+ const ctx = {
159
+ sessionId,
160
+ logPath,
161
+ projectId: options.projectId || "",
162
+ taskId: options.taskId || "",
163
+ };
164
+ /** Maps local_id strings (assigned by the agent) to real task IDs, scoped to this stream. */
165
+ const subtaskLocalIdMap = new Map();
166
+ processorRegistry.register(ctx);
167
+ // Register the bind listener synchronously alongside register() to close the race
168
+ // window where lateBind() could fire between register and the async IIFE starting.
169
+ processorRegistry.onBind(sessionId, () => {
170
+ replayLoggedEvents(ctx, subtaskLocalIdMap);
171
+ });
26
172
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
27
173
  (async () => {
28
- /** Maps local_id strings (assigned by the agent) to real task IDs, scoped to this stream. */
29
- const subtaskLocalIdMap = new Map();
30
174
  try {
31
175
  logWriter.initLog(logPath);
32
176
  sessionStore.updateSessionStatus(sessionId, "running");
@@ -41,110 +185,19 @@ export function processEventStream(events, options) {
41
185
  logWriter.writeEvent(logPath, sessionEvent);
42
186
  streamHub.publish(sessionEvent);
43
187
  // Intercept finding events and store + broadcast them
44
- if (event.type === "finding" && projectId) {
45
- try {
46
- const data = JSON.parse(event.content);
47
- const findingId = uuid();
48
- findingStore.postFinding(findingId, projectId, taskId || "", sessionId, data.category || "general", data.title || "Untitled", data.content || "", data.tags || []);
49
- broadcast({ type: "finding_posted", payload: { projectId, findingId } });
50
- logger.info({ findingId, projectId, title: data.title }, "Finding stored");
51
- }
52
- catch (err) {
53
- logger.error({ err, projectId, taskId }, "Failed to store finding");
54
- }
188
+ if (event.type === "finding" && ctx.projectId) {
189
+ processFindingEvent(ctx, event.content, sessionId);
55
190
  }
56
191
  // Intercept subtask creation events and create child tasks
57
- if (event.type === "subtask_create" && taskId) {
58
- try {
59
- const data = JSON.parse(event.content);
60
- const parentTask = taskStore.getTask(taskId);
61
- if (!parentTask) {
62
- logger.warn({ taskId }, "Subtask creation failed: parent task not found");
63
- }
64
- else if (!parentTask.canDecompose) {
65
- logger.warn({ taskId }, "Subtask creation failed: parent task cannot decompose");
66
- }
67
- else {
68
- const project = projectStore.getProject(parentTask.projectId);
69
- if (!project) {
70
- logger.warn({ projectId: parentTask.projectId }, "Subtask creation failed: project not found");
71
- }
72
- else {
73
- // Validate required fields
74
- const title = typeof data.title === "string" ? data.title.trim() : "";
75
- const description = typeof data.description === "string" ? data.description.trim() : "";
76
- if (!title || !description) {
77
- logger.warn({ taskId, rawTitle: data.title, rawDescription: data.description }, "Subtask creation failed: invalid title or description");
78
- }
79
- else {
80
- // Normalize and validate depends_on, local_id, and can_decompose
81
- const dependsOn = Array.isArray(data.depends_on)
82
- ? data.depends_on.filter((d) => typeof d === "string").map(d => d.trim()).filter(Boolean)
83
- : [];
84
- const localId = typeof data.local_id === "string" ? data.local_id.trim() : "";
85
- const canDecompose = typeof data.can_decompose === "boolean" ? data.can_decompose : false;
86
- // Resolve depends_on local IDs to real task IDs
87
- const resolvedDeps = [];
88
- for (const localDep of dependsOn) {
89
- const realId = subtaskLocalIdMap.get(localDep);
90
- if (realId) {
91
- resolvedDeps.push(realId);
92
- }
93
- else {
94
- logger.warn({ localDep, taskId }, "Subtask dependency local_id not found, skipping");
95
- }
96
- }
97
- const subtaskId = uuid().slice(0, 8);
98
- const environmentId = parentTask.environmentId || project.defaultEnvironmentId;
99
- taskStore.createTask(subtaskId, parentTask.projectId, title, description, environmentId, resolvedDeps, slugify(project.name), taskId, canDecompose);
100
- // Record the local_id → real ID mapping, detecting duplicates
101
- if (localId) {
102
- if (subtaskLocalIdMap.has(localId)) {
103
- logger.warn({
104
- localId,
105
- existingSubtaskId: subtaskLocalIdMap.get(localId),
106
- newSubtaskId: subtaskId,
107
- parentTaskId: taskId,
108
- }, "Duplicate subtask local_id encountered; keeping existing mapping");
109
- }
110
- else {
111
- subtaskLocalIdMap.set(localId, subtaskId);
112
- }
113
- }
114
- const row = taskStore.getTask(subtaskId);
115
- broadcast({
116
- type: "task_created",
117
- payload: { task: row ? { ...row, dependsOn: safeParseJsonArray(row.dependsOn) } : null },
118
- });
119
- logger.info({ subtaskId, parentTaskId: taskId, title }, "Subtask created");
120
- }
121
- }
122
- }
123
- }
124
- catch (err) {
125
- logger.error({ err, taskId }, "Failed to create subtask");
126
- }
192
+ if (event.type === "subtask_create" && ctx.taskId) {
193
+ processSubtaskEvent(ctx, event.content, subtaskLocalIdMap);
127
194
  }
128
195
  if (event.type === "status") {
129
196
  if (event.content === "waiting_input") {
130
197
  sessionStore.updateSessionStatus(sessionId, "waiting_input");
131
- if (taskId) {
132
- const t = taskStore.getTask(taskId);
133
- if (t && t.status === "in_progress") {
134
- taskStore.updateTaskStatus(taskId, "waiting_input");
135
- broadcast({ type: "task_updated", payload: { taskId, projectId } });
136
- }
137
- }
138
198
  }
139
199
  else if (event.content === "running") {
140
200
  sessionStore.updateSessionStatus(sessionId, "running");
141
- if (taskId) {
142
- const t = taskStore.getTask(taskId);
143
- if (t && t.status === "waiting_input") {
144
- taskStore.updateTaskStatus(taskId, "in_progress");
145
- broadcast({ type: "task_updated", payload: { taskId, projectId } });
146
- }
147
- }
148
201
  }
149
202
  else if (event.content === "completed") {
150
203
  sessionStore.updateSession(sessionId, "completed");
@@ -155,12 +208,21 @@ export function processEventStream(events, options) {
155
208
  else if (event.content === "killed") {
156
209
  sessionStore.updateSession(sessionId, "killed");
157
210
  }
211
+ // Broadcast task_updated on status changes so frontend re-fetches computed status.
212
+ // This covers both terminal events (completed/failed/killed) and non-terminal
213
+ // transitions (running, waiting_input) that affect the computed task status.
214
+ if (ctx.taskId && ["completed", "failed", "killed", "running", "waiting_input"].includes(event.content)) {
215
+ broadcast({ type: "task_updated", payload: { taskId: ctx.taskId, projectId: ctx.projectId } });
216
+ }
158
217
  }
159
218
  }
160
219
  // Fallback: if stream ended without a terminal status event, mark completed
161
220
  const current = sessionStore.getSession(sessionId);
162
221
  if (current && !TERMINAL_STATUSES.includes(current.status)) {
163
222
  sessionStore.updateSession(sessionId, "completed");
223
+ if (ctx.taskId) {
224
+ broadcast({ type: "task_updated", payload: { taskId: ctx.taskId, projectId: ctx.projectId } });
225
+ }
164
226
  }
165
227
  }
166
228
  catch (err) {
@@ -188,14 +250,17 @@ export function processEventStream(events, options) {
188
250
  }));
189
251
  onError?.(err);
190
252
  }
253
+ if (ctx.taskId) {
254
+ broadcast({ type: "task_updated", payload: { taskId: ctx.taskId, projectId: ctx.projectId } });
255
+ }
191
256
  }
192
257
  finally {
258
+ processorRegistry.unregister(sessionId);
193
259
  logWriter.endSession(logPath);
194
260
  try {
195
261
  writeTranscript(logPath);
196
262
  }
197
263
  catch { /* non-critical */ }
198
- onComplete?.();
199
264
  }
200
265
  })();
201
266
  }