astrocode-workflow 0.1.59 → 0.3.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.
package/src/ui/inject.ts CHANGED
@@ -1,62 +1,107 @@
1
+ // src/ui/inject.ts
2
+
1
3
  let isInjecting = false;
4
+
2
5
  const injectionQueue: Array<{
3
6
  ctx: any;
4
- sessionId: string;
7
+ sessionId?: string;
5
8
  text: string;
6
9
  agent?: string;
10
+ toast?: { title: string; message: string; variant?: "info" | "success" | "warning" | "error"; durationMs?: number };
11
+ retry?: { maxAttempts?: number; baseDelayMs?: number };
7
12
  }> = [];
8
13
 
9
- async function processQueue() {
10
- if (isInjecting || injectionQueue.length === 0) return;
14
+ function sleep(ms: number) {
15
+ return new Promise((r) => setTimeout(r, ms));
16
+ }
11
17
 
12
- isInjecting = true;
13
- const opts = injectionQueue.shift();
18
+ function resolveSessionId(ctx: any, sessionId?: string): string | null {
19
+ if (sessionId) return sessionId;
20
+ const direct = (ctx as any)?.sessionID ?? (ctx as any)?.sessionId ?? (ctx as any)?.session?.id;
21
+ if (typeof direct === "string" && direct.length > 0) return direct;
22
+ return null;
23
+ }
14
24
 
15
- if (!opts) {
16
- isInjecting = false;
17
- return;
25
+ async function tryInjectOnce(opts: { ctx: any; sessionId: string; text: string; agent: string }): Promise<void> {
26
+ const { ctx, sessionId, text, agent } = opts;
27
+
28
+ // Prefer explicit chat prompt API
29
+ const promptApi = (ctx as any)?.client?.session?.prompt;
30
+ if (!promptApi) {
31
+ throw new Error("API not available (ctx.client.session.prompt)");
18
32
  }
19
33
 
34
+ const prefixedText = `[${agent}]\n\n${text}`;
35
+
36
+ // Some hosts reject unknown fields; keep body minimal and stable.
37
+ await promptApi({
38
+ path: { id: sessionId },
39
+ body: {
40
+ parts: [{ type: "text", text: prefixedText }],
41
+ },
42
+ });
43
+ }
44
+
45
+ async function processQueue() {
46
+ if (isInjecting) return;
47
+ if (injectionQueue.length === 0) return;
48
+
49
+ isInjecting = true;
50
+
20
51
  try {
21
- const { ctx, sessionId, text, agent = "Astro" } = opts;
22
- const prefixedText = `[${agent}]\n\n${text}`;
52
+ while (injectionQueue.length > 0) {
53
+ const item = injectionQueue.shift();
54
+ if (!item) continue;
23
55
 
24
- // Basic validation
25
- if (!sessionId) {
26
- console.warn("[Astrocode] Skipping injection: No sessionId provided");
27
- return;
28
- }
56
+ const { ctx, text, agent = "Astro" } = item;
57
+ const sessionId = resolveSessionId(ctx, item.sessionId);
29
58
 
30
- if (!ctx?.client?.session?.prompt) {
31
- console.warn("[Astrocode] Skipping injection: API not available (ctx.client.session.prompt)");
32
- return;
33
- }
59
+ if (!sessionId) {
60
+ // Drop on floor: we cannot recover without a session id.
61
+ // Keep draining the queue so we don't stall.
62
+ // eslint-disable-next-line no-console
63
+ console.warn("[Astrocode] Injection skipped: no sessionId");
64
+ continue;
65
+ }
66
+
67
+ const maxAttempts = item.retry?.maxAttempts ?? 4;
68
+ const baseDelayMs = item.retry?.baseDelayMs ?? 250;
69
+
70
+ let lastErr: any = null;
71
+
72
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
73
+ try {
74
+ await tryInjectOnce({ ctx, sessionId, text, agent });
75
+ lastErr = null;
76
+ break;
77
+ } catch (e) {
78
+ lastErr = e;
79
+ const delay = baseDelayMs * Math.pow(2, attempt - 1); // 250, 500, 1000, 2000
80
+ // eslint-disable-next-line no-console
81
+ console.warn(`[Astrocode] Injection attempt ${attempt}/${maxAttempts} failed: ${String(e)}; retrying in ${delay}ms`);
82
+ await sleep(delay);
83
+ }
84
+ }
34
85
 
35
- await ctx.client.session.prompt({
36
- path: { id: sessionId },
37
- body: {
38
- parts: [{ type: "text", text: prefixedText }],
39
- // Pass agent context for systems that support it
40
- agent: agent,
41
- },
42
- });
43
- } catch (error) {
44
- console.warn(`[Astrocode] Injection failed: ${error}`);
86
+ if (lastErr) {
87
+ // eslint-disable-next-line no-console
88
+ console.warn(`[Astrocode] Injection failed permanently after ${maxAttempts} attempts: ${String(lastErr)}`);
89
+ }
90
+ }
45
91
  } finally {
46
92
  isInjecting = false;
47
- // Process next item immediately
48
- if (injectionQueue.length > 0) {
49
- setImmediate(processQueue);
50
- }
51
93
  }
52
94
  }
53
95
 
54
- export async function injectChatPrompt(opts: {
55
- ctx: any;
56
- sessionId: string;
57
- text: string;
58
- agent?: string;
59
- }) {
60
- injectionQueue.push(opts);
61
- processQueue();
96
+ export async function injectChatPrompt(opts: { ctx: any; sessionId?: string; text: string; agent?: string }) {
97
+ injectionQueue.push({
98
+ ctx: opts.ctx,
99
+ sessionId: opts.sessionId,
100
+ text: opts.text,
101
+ agent: opts.agent ?? "Astro",
102
+ retry: { maxAttempts: 4, baseDelayMs: 250 },
103
+ });
104
+
105
+ // Fire-and-forget; queue drain is serialized by isInjecting.
106
+ void processQueue();
62
107
  }
@@ -7,6 +7,9 @@ import { nowISO } from "../shared/time";
7
7
  import { newEventId, newRunId, newStageRunId } from "../state/ids";
8
8
  import { warn } from "../shared/log";
9
9
  import { sha256Hex } from "../shared/hash";
10
+ import { SCHEMA_VERSION } from "../state/schema";
11
+ import type { ToastOptions } from "../ui/toasts";
12
+ import { injectChatPrompt } from "../ui/inject";
10
13
 
11
14
  export const EVENT_TYPES = {
12
15
  RUN_STARTED: "run.started",
@@ -19,24 +22,65 @@ export const EVENT_TYPES = {
19
22
  } as const;
20
23
 
21
24
  /**
22
- * PLANNING-FIRST REDESIGN
23
- * ----------------------
24
- * Old behavior: mutate an approved story into a planning/decomposition instruction.
25
- * New behavior: NEVER mutate story title/body.
26
- *
27
- * Planning-first is a run-scoped directive stored in `injects` (scope = run:<run_id>).
28
- *
29
- * Deterministic trigger (config-driven):
30
- * - config.workflow.genesis_planning:
31
- * - "off" => never attach directive
32
- * - "first_story_only"=> attach only when story_key === "S-0001"
33
- * - "always" => attach for every run
25
+ * UI HOOKS
26
+ * --------
27
+ * This workflow module is DB-first. UI emission is optional and happens AFTER the DB tx commits.
34
28
  *
35
- * Contract: DB is already initialized before workflow is used:
36
- * - schema tables exist
37
- * - repo_state singleton row (id=1) exists
29
+ * Contract:
30
+ * - If you pass ui.ctx + ui.sessionId, we will inject a visible chat message deterministically.
31
+ * - If you pass ui.toast, we will also toast (throttling is handled by the toast manager).
38
32
  */
33
+ export type WorkflowUi = {
34
+ ctx: any;
35
+ sessionId: string;
36
+ agentName?: string; // label for injected chat messages
37
+ toast?: (t: ToastOptions) => Promise<void>;
38
+ };
39
+
40
+ async function emitUi(ui: WorkflowUi | undefined, text: string, toast?: ToastOptions): Promise<void> {
41
+ if (!ui) return;
42
+
43
+ // Prefer toast (if provided) AND also inject chat (for audit trail / visibility).
44
+ // If you want toast-only, pass a toast function and omit ctx/sessionId.
45
+ if (toast && ui.toast) {
46
+ try {
47
+ await ui.toast(toast);
48
+ } catch {
49
+ // non-fatal
50
+ }
51
+ }
52
+
53
+ try {
54
+ await injectChatPrompt({
55
+ ctx: ui.ctx,
56
+ sessionId: ui.sessionId,
57
+ text,
58
+ agent: ui.agentName ?? "Astro",
59
+ });
60
+ } catch {
61
+ // non-fatal (workflow correctness is DB-based)
62
+ }
63
+ }
39
64
 
65
+ function tableExists(db: SqliteDb, tableName: string): boolean {
66
+ try {
67
+ const row = db
68
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
69
+ .get(tableName) as { name?: string } | undefined;
70
+ return row?.name === tableName;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * PLANNING-FIRST REDESIGN
78
+ * ----------------------
79
+ * - Never mutate story title/body.
80
+ * - Planning-first becomes a run-scoped inject stored in `injects` (scope = run:<run_id>).
81
+ * - Trigger is deterministic via config.workflow.genesis_planning:
82
+ * - "off" | "first_story_only" | "always"
83
+ */
40
84
  export type NextAction =
41
85
  | { kind: "idle"; reason: "no_approved_stories" }
42
86
  | { kind: "start_run"; story_key: string }
@@ -97,7 +141,7 @@ export function decideNextAction(db: SqliteDb, config: AstrocodeConfig): NextAct
97
141
  return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
98
142
  }
99
143
 
100
- warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
144
+ warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.status });
101
145
  return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
102
146
  }
103
147
 
@@ -114,7 +158,7 @@ type GenesisPlanningMode = "off" | "first_story_only" | "always";
114
158
  function getGenesisPlanningMode(config: AstrocodeConfig): GenesisPlanningMode {
115
159
  const raw = (config as any)?.workflow?.genesis_planning;
116
160
  if (raw === "off" || raw === "first_story_only" || raw === "always") return raw;
117
- if (raw != null) warn(`Invalid workflow.genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
161
+ warn(`Invalid genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
118
162
  return "first_story_only";
119
163
  }
120
164
 
@@ -126,6 +170,8 @@ function shouldAttachPlanningDirective(config: AstrocodeConfig, story: StoryRow)
126
170
  }
127
171
 
128
172
  function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow, pipeline: StageKey[]) {
173
+ if (!tableExists(db, "injects")) return;
174
+
129
175
  const now = nowISO();
130
176
  const injectId = `inj_${runId}_genesis_plan`;
131
177
 
@@ -145,16 +191,9 @@ function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow
145
191
  ``,
146
192
  ].join("\n");
147
193
 
148
- let hash: string | null = null;
149
- try {
150
- hash = sha256Hex(body);
151
- } catch {
152
- // Hash is optional; directive must never be blocked by hashing.
153
- hash = null;
154
- }
194
+ const hash = sha256Hex(body);
155
195
 
156
196
  try {
157
- // Do not clobber user edits. If it exists, we leave it.
158
197
  db.prepare(
159
198
  `
160
199
  INSERT OR IGNORE INTO injects (
@@ -169,39 +208,12 @@ function attachRunPlanningDirective(db: SqliteDb, runId: string, story: StoryRow
169
208
 
170
209
  db.prepare(
171
210
  "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
172
- ).run(
173
- newEventId(),
174
- runId,
175
- EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED,
176
- JSON.stringify({ story_key: story.story_key, inject_id: injectId }),
177
- now
178
- );
211
+ ).run(newEventId(), runId, EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED, JSON.stringify({ story_key: story.story_key, inject_id: injectId }), now);
179
212
  } catch (e) {
180
- // Helpful, never required for correctness.
181
213
  warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
182
214
  }
183
215
  }
184
216
 
185
- function updateRepoStateLastEvent(db: SqliteDb, now: string, fields: { last_run_id?: string; last_story_key?: string }) {
186
- // Contract: repo_state row exists. If not, fail deterministically (don’t “bootstrap” here).
187
- const res = db
188
- .prepare(
189
- `
190
- UPDATE repo_state
191
- SET last_run_id = COALESCE(?, last_run_id),
192
- last_story_key = COALESCE(?, last_story_key),
193
- last_event_at = ?,
194
- updated_at = ?
195
- WHERE id = 1
196
- `
197
- )
198
- .run(fields.last_run_id ?? null, fields.last_story_key ?? null, now, now);
199
-
200
- if (!res || res.changes === 0) {
201
- throw new Error("repo_state missing (id=1). Database not initialized; run init must be performed before workflow.");
202
- }
203
- }
204
-
205
217
  export function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): { run_id: string } {
206
218
  return withTx(db, () => {
207
219
  const story = getStory(db, storyKey);
@@ -235,19 +247,31 @@ export function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKe
235
247
  attachRunPlanningDirective(db, run_id, story, pipeline);
236
248
  }
237
249
 
238
- updateRepoStateLastEvent(db, now, { last_run_id: run_id, last_story_key: storyKey });
250
+ db.prepare(`
251
+ INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
252
+ VALUES (1, ?, ?, ?, ?, ?, ?)
253
+ ON CONFLICT(id) DO UPDATE SET
254
+ last_run_id=excluded.last_run_id,
255
+ last_story_key=excluded.last_story_key,
256
+ last_event_at=excluded.last_event_at,
257
+ updated_at=excluded.updated_at
258
+ `).run(SCHEMA_VERSION, now, now, run_id, storyKey, now);
239
259
 
240
260
  return { run_id };
241
261
  });
242
262
  }
243
263
 
244
- export function startStage(
264
+ /**
265
+ * STAGE MOVEMENT (START) — now async so UI injection is deterministic.
266
+ */
267
+ export async function startStage(
245
268
  db: SqliteDb,
246
269
  runId: string,
247
270
  stageKey: StageKey,
248
- meta?: { subagent_type?: string; subagent_session_id?: string }
249
- ) {
250
- return withTx(db, () => {
271
+ meta?: { subagent_type?: string; subagent_session_id?: string; ui?: WorkflowUi }
272
+ ): Promise<void> {
273
+ // Do DB work inside tx, capture what we need for UI outside.
274
+ const payload = withTx(db, () => {
251
275
  const now = nowISO();
252
276
 
253
277
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
@@ -268,12 +292,40 @@ export function startStage(
268
292
  "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
269
293
  ).run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
270
294
 
271
- updateRepoStateLastEvent(db, now, {});
295
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
296
+
297
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
298
+
299
+ return {
300
+ now,
301
+ story_key: run.story_key,
302
+ story_title: story?.title ?? "",
303
+ };
272
304
  });
305
+
306
+ // Deterministic UI emission AFTER commit (never inside tx).
307
+ await emitUi(
308
+ meta?.ui,
309
+ [
310
+ `🟦 Stage started`,
311
+ `- Run: \`${runId}\``,
312
+ `- Stage: \`${stageKey}\``,
313
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
314
+ ].join("\n"),
315
+ {
316
+ title: "Stage started",
317
+ message: `${stageKey} (${payload.story_key})`,
318
+ variant: "info",
319
+ durationMs: 2500,
320
+ }
321
+ );
273
322
  }
274
323
 
275
- export function completeRun(db: SqliteDb, runId: string) {
276
- return withTx(db, () => {
324
+ /**
325
+ * STAGE CLOSED (RUN COMPLETED) now async so UI injection is deterministic.
326
+ */
327
+ export async function completeRun(db: SqliteDb, runId: string, ui?: WorkflowUi): Promise<void> {
328
+ const payload = withTx(db, () => {
277
329
  const now = nowISO();
278
330
 
279
331
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
@@ -294,12 +346,34 @@ export function completeRun(db: SqliteDb, runId: string) {
294
346
  "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
295
347
  ).run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
296
348
 
297
- updateRepoStateLastEvent(db, now, {});
349
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
350
+
351
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
352
+
353
+ return { now, story_key: run.story_key, story_title: story?.title ?? "" };
298
354
  });
355
+
356
+ await emitUi(
357
+ ui,
358
+ [
359
+ `✅ Run completed`,
360
+ `- Run: \`${runId}\``,
361
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
362
+ ].join("\n"),
363
+ {
364
+ title: "Run completed",
365
+ message: `${payload.story_key} — done`,
366
+ variant: "success",
367
+ durationMs: 3000,
368
+ }
369
+ );
299
370
  }
300
371
 
301
- export function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string) {
302
- return withTx(db, () => {
372
+ /**
373
+ * STAGE CLOSED (RUN FAILED) now async so UI injection is deterministic.
374
+ */
375
+ export async function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string, ui?: WorkflowUi): Promise<void> {
376
+ const payload = withTx(db, () => {
303
377
  const now = nowISO();
304
378
 
305
379
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
@@ -315,14 +389,34 @@ export function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorTe
315
389
  "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
316
390
  ).run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
317
391
 
318
- updateRepoStateLastEvent(db, now, {});
392
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
393
+
394
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key) as any;
395
+
396
+ return { now, story_key: run.story_key, story_title: story?.title ?? "" };
319
397
  });
398
+
399
+ await emitUi(
400
+ ui,
401
+ [
402
+ `⛔ Run failed`,
403
+ `- Run: \`${runId}\``,
404
+ `- Stage: \`${stageKey}\``,
405
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
406
+ `- Error: ${errorText}`,
407
+ ].join("\n"),
408
+ {
409
+ title: "Run failed",
410
+ message: `${stageKey}: ${errorText}`,
411
+ variant: "error",
412
+ durationMs: 4500,
413
+ }
414
+ );
320
415
  }
321
416
 
322
417
  export function abortRun(db: SqliteDb, runId: string, reason: string) {
323
418
  return withTx(db, () => {
324
419
  const now = nowISO();
325
-
326
420
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId) as RunRow | undefined;
327
421
  if (!run) throw new Error(`Run not found: ${runId}`);
328
422
 
@@ -336,6 +430,6 @@ export function abortRun(db: SqliteDb, runId: string, reason: string) {
336
430
  "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)"
337
431
  ).run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
338
432
 
339
- updateRepoStateLastEvent(db, now, {});
433
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
340
434
  });
341
435
  }