astrocode-workflow 0.1.59 → 0.2.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.
@@ -2,28 +2,41 @@ import { tool } from "@opencode-ai/plugin/tool";
2
2
  import { withTx } from "../state/db";
3
3
  import { nowISO } from "../shared/time";
4
4
  import { sha256Hex } from "../shared/hash";
5
- const VALID_INJECT_TYPES = ['note', 'policy', 'reminder', 'debug'];
5
+ const VALID_INJECT_TYPES = ["note", "policy", "reminder", "debug"];
6
6
  function validateInjectType(type) {
7
7
  if (!VALID_INJECT_TYPES.includes(type)) {
8
- throw new Error(`Invalid inject type "${type}". Must be one of: ${VALID_INJECT_TYPES.join(', ')}`);
8
+ throw new Error(`Invalid inject type "${type}". Must be one of: ${VALID_INJECT_TYPES.join(", ")}`);
9
9
  }
10
10
  return type;
11
11
  }
12
12
  function validateTimestamp(timestamp) {
13
13
  if (!timestamp)
14
14
  return null;
15
- // Check if it's a valid ISO 8601 timestamp with Z suffix
15
+ // Strict ISO 8601 UTC with Z suffix, sortable as string.
16
16
  const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
17
17
  if (!isoRegex.test(timestamp)) {
18
18
  throw new Error(`Invalid timestamp format. Expected ISO 8601 UTC with Z suffix (e.g., "2026-01-23T13:05:19.000Z"), got: "${timestamp}"`);
19
19
  }
20
- // Additional validation: ensure it's parseable and represents a valid date
21
- const parsed = new Date(timestamp);
22
- if (isNaN(parsed.getTime())) {
20
+ const parsed = Date.parse(timestamp);
21
+ if (!Number.isFinite(parsed)) {
23
22
  throw new Error(`Invalid timestamp: "${timestamp}" does not represent a valid date`);
24
23
  }
25
24
  return timestamp;
26
25
  }
26
+ function parseJsonStringArray(name, raw) {
27
+ let v;
28
+ try {
29
+ v = JSON.parse(raw);
30
+ }
31
+ catch (e) {
32
+ const msg = e instanceof Error ? e.message : String(e);
33
+ throw new Error(`${name} must be valid JSON. Parse error: ${msg}`);
34
+ }
35
+ if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) {
36
+ throw new Error(`${name} must be a JSON array of strings`);
37
+ }
38
+ return v;
39
+ }
27
40
  function newInjectId() {
28
41
  return `inj_${Date.now()}_${Math.random().toString(16).slice(2)}`;
29
42
  }
@@ -46,15 +59,34 @@ export function createAstroInjectPutTool(opts) {
46
59
  const id = inject_id ?? newInjectId();
47
60
  const now = nowISO();
48
61
  const sha = sha256Hex(body_md);
49
- // Validate inputs
50
62
  const validatedType = validateInjectType(type);
51
63
  const validatedExpiresAt = validateTimestamp(expires_at);
64
+ // Ensure tags_json is at least valid JSON (we do not enforce schema here beyond validity).
65
+ try {
66
+ JSON.parse(tags_json);
67
+ }
68
+ catch (e) {
69
+ const msg = e instanceof Error ? e.message : String(e);
70
+ throw new Error(`tags_json must be valid JSON. Parse error: ${msg}`);
71
+ }
52
72
  return withTx(db, () => {
53
73
  const existing = db.prepare("SELECT inject_id FROM injects WHERE inject_id=?").get(id);
54
74
  if (existing) {
55
- // Use INSERT ... ON CONFLICT for atomic updates
56
75
  db.prepare(`
57
- INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at)
76
+ INSERT INTO injects (
77
+ inject_id,
78
+ type,
79
+ title,
80
+ body_md,
81
+ tags_json,
82
+ scope,
83
+ source,
84
+ priority,
85
+ expires_at,
86
+ sha256,
87
+ created_at,
88
+ updated_at
89
+ )
58
90
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
59
91
  ON CONFLICT(inject_id) DO UPDATE SET
60
92
  type=excluded.type,
@@ -67,7 +99,7 @@ export function createAstroInjectPutTool(opts) {
67
99
  expires_at=excluded.expires_at,
68
100
  sha256=excluded.sha256,
69
101
  updated_at=excluded.updated_at
70
- `).run(id, type, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
102
+ `).run(id, validatedType, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
71
103
  return `✅ Updated inject ${id}: ${title}`;
72
104
  }
73
105
  db.prepare("INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(id, validatedType, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
@@ -93,10 +125,18 @@ export function createAstroInjectListTool(opts) {
93
125
  params.push(scope);
94
126
  }
95
127
  if (type) {
128
+ // Keep list tool permissive (debugging), but still prevents obvious garbage if used.
129
+ validateInjectType(type);
96
130
  where.push("type = ?");
97
131
  params.push(type);
98
132
  }
99
- const sql = `SELECT inject_id, type, title, scope, priority, created_at, updated_at FROM injects ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY priority DESC, updated_at DESC LIMIT ?`;
133
+ const sql = `
134
+ SELECT inject_id, type, title, scope, priority, created_at, updated_at
135
+ FROM injects
136
+ ${where.length ? "WHERE " + where.join(" AND ") : ""}
137
+ ORDER BY priority DESC, updated_at DESC
138
+ LIMIT ?
139
+ `;
100
140
  const rows = db.prepare(sql).all(...params, limit);
101
141
  return JSON.stringify(rows, null, 2);
102
142
  },
@@ -134,7 +174,13 @@ export function createAstroInjectSearchTool(opts) {
134
174
  where.push("scope = ?");
135
175
  params.push(scope);
136
176
  }
137
- const sql = `SELECT inject_id, type, title, scope, priority, updated_at FROM injects WHERE ${where.join(" AND ")} ORDER BY priority DESC, updated_at DESC LIMIT ?`;
177
+ const sql = `
178
+ SELECT inject_id, type, title, scope, priority, updated_at
179
+ FROM injects
180
+ WHERE ${where.join(" AND ")}
181
+ ORDER BY priority DESC, updated_at DESC
182
+ LIMIT ?
183
+ `;
138
184
  const rows = db.prepare(sql).all(...params, limit);
139
185
  return JSON.stringify(rows, null, 2);
140
186
  },
@@ -142,7 +188,13 @@ export function createAstroInjectSearchTool(opts) {
142
188
  }
143
189
  export function selectEligibleInjects(db, opts) {
144
190
  const { nowIso, scopeAllowlist, typeAllowlist, limit = 50 } = opts;
145
- // Build placeholders safely
191
+ if (!Array.isArray(scopeAllowlist) || scopeAllowlist.length === 0) {
192
+ throw new Error("selectEligibleInjects: scopeAllowlist must be a non-empty array");
193
+ }
194
+ if (!Array.isArray(typeAllowlist) || typeAllowlist.length === 0) {
195
+ throw new Error("selectEligibleInjects: typeAllowlist must be a non-empty array");
196
+ }
197
+ // Build placeholders safely (guaranteed non-empty).
146
198
  const scopeQs = scopeAllowlist.map(() => "?").join(", ");
147
199
  const typeQs = typeAllowlist.map(() => "?").join(", ");
148
200
  const sql = `
@@ -168,8 +220,11 @@ export function createAstroInjectEligibleTool(opts) {
168
220
  },
169
221
  execute: async ({ scopes_json, types_json, limit }) => {
170
222
  const now = nowISO();
171
- const scopes = JSON.parse(scopes_json);
172
- const types = JSON.parse(types_json);
223
+ const scopes = parseJsonStringArray("scopes_json", scopes_json);
224
+ const types = parseJsonStringArray("types_json", types_json);
225
+ // Validate types against the known set to keep selection sane.
226
+ for (const t of types)
227
+ validateInjectType(t);
173
228
  const rows = selectEligibleInjects(db, {
174
229
  nowIso: now,
175
230
  scopeAllowlist: scopes,
@@ -190,30 +245,38 @@ export function createAstroInjectDebugDueTool(opts) {
190
245
  },
191
246
  execute: async ({ scopes_json, types_json }) => {
192
247
  const now = nowISO();
193
- const scopes = JSON.parse(scopes_json);
194
- const types = JSON.parse(types_json);
195
- // Get ALL injects to analyze filtering
248
+ const nowMs = Date.parse(now);
249
+ const scopes = parseJsonStringArray("scopes_json", scopes_json);
250
+ const types = parseJsonStringArray("types_json", types_json);
251
+ for (const t of types)
252
+ validateInjectType(t);
196
253
  const allInjects = db.prepare("SELECT * FROM injects").all();
197
254
  let total = allInjects.length;
198
255
  let selected = 0;
199
256
  let skippedExpired = 0;
200
257
  let skippedScope = 0;
201
258
  let skippedType = 0;
259
+ let skippedUnparseableExpiresAt = 0;
202
260
  const excludedReasons = [];
203
261
  const selectedInjects = [];
204
262
  for (const inject of allInjects) {
205
263
  const reasons = [];
206
- // Check expiration
207
- if (inject.expires_at && inject.expires_at <= now) {
208
- reasons.push("expired");
209
- skippedExpired++;
264
+ // Expiration: parse to ms for correctness across legacy rows.
265
+ if (inject.expires_at) {
266
+ const expMs = Date.parse(String(inject.expires_at));
267
+ if (!Number.isFinite(expMs)) {
268
+ reasons.push("expires_at_unparseable");
269
+ skippedUnparseableExpiresAt++;
270
+ }
271
+ else if (expMs <= nowMs) {
272
+ reasons.push("expired");
273
+ skippedExpired++;
274
+ }
210
275
  }
211
- // Check scope
212
276
  if (!scopes.includes(inject.scope)) {
213
277
  reasons.push("scope");
214
278
  skippedScope++;
215
279
  }
216
- // Check type
217
280
  if (!types.includes(inject.type)) {
218
281
  reasons.push("type");
219
282
  skippedType++;
@@ -222,7 +285,7 @@ export function createAstroInjectDebugDueTool(opts) {
222
285
  excludedReasons.push({
223
286
  inject_id: inject.inject_id,
224
287
  title: inject.title,
225
- reasons: reasons,
288
+ reasons,
226
289
  scope: inject.scope,
227
290
  type: inject.type,
228
291
  expires_at: inject.expires_at,
@@ -249,9 +312,10 @@ export function createAstroInjectDebugDueTool(opts) {
249
312
  excluded_total: total - selected,
250
313
  skipped_breakdown: {
251
314
  expired: skippedExpired,
315
+ expires_at_unparseable: skippedUnparseableExpiresAt,
252
316
  scope: skippedScope,
253
317
  type: skippedType,
254
- }
318
+ },
255
319
  },
256
320
  selected_injects: selectedInjects,
257
321
  excluded_injects: excludedReasons,
@@ -1,6 +1,20 @@
1
- export declare function injectChatPrompt(opts: {
1
+ type QueueItem = {
2
2
  ctx: any;
3
3
  sessionId: string;
4
4
  text: string;
5
5
  agent?: string;
6
- }): Promise<void>;
6
+ };
7
+ /**
8
+ * Enqueue an injection and ensure the worker is running.
9
+ * Does NOT wait for delivery — use `flushChatPrompts()` to wait.
10
+ */
11
+ export declare function enqueueChatPrompt(opts: QueueItem): void;
12
+ /**
13
+ * Wait until all queued injections have been processed (sent or exhausted retries).
14
+ */
15
+ export declare function flushChatPrompts(): Promise<void>;
16
+ /**
17
+ * Deterministic helper: enqueue + flush (recommended for stage boundaries).
18
+ */
19
+ export declare function injectChatPrompt(opts: QueueItem): Promise<void>;
20
+ export {};
package/dist/ui/inject.js CHANGED
@@ -1,47 +1,118 @@
1
- let isInjecting = false;
1
+ // src/ui/inject.ts
2
+ //
3
+ // Deterministic chat injection:
4
+ // - Always enqueue
5
+ // - Process sequentially (per-process single worker)
6
+ // - Retries with backoff
7
+ // - flush() lets callers wait until injections are actually sent
8
+ //
9
+ // IMPORTANT: Callers who need reliability must `await injectChatPrompt(...)`
10
+ // or `await flushChatPrompts()` after enqueueing.
2
11
  const injectionQueue = [];
3
- async function processQueue() {
4
- if (isInjecting || injectionQueue.length === 0)
12
+ let workerRunning = false;
13
+ // Used to let callers await "queue drained"
14
+ let drainWaiters = [];
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+ function resolveDrainWaitersIfIdle() {
19
+ if (workerRunning)
5
20
  return;
6
- isInjecting = true;
7
- const opts = injectionQueue.shift();
8
- if (!opts) {
9
- isInjecting = false;
21
+ if (injectionQueue.length !== 0)
22
+ return;
23
+ const waiters = drainWaiters;
24
+ drainWaiters = [];
25
+ for (const w of waiters)
26
+ w();
27
+ }
28
+ function getPromptApi(ctx) {
29
+ const fn = ctx?.client?.session?.prompt;
30
+ return typeof fn === "function" ? fn.bind(ctx.client.session) : null;
31
+ }
32
+ async function sendWithRetries(opts) {
33
+ const { ctx, sessionId, text } = opts;
34
+ const agent = opts.agent ?? "Astro";
35
+ const prefixedText = `[${agent}]\n\n${text}`;
36
+ if (!sessionId) {
37
+ console.warn("[Astrocode] Injection skipped: missing sessionId");
10
38
  return;
11
39
  }
12
- try {
13
- const { ctx, sessionId, text, agent = "Astro" } = opts;
14
- const prefixedText = `[${agent}]\n\n${text}`;
15
- // Basic validation
16
- if (!sessionId) {
17
- console.warn("[Astrocode] Skipping injection: No sessionId provided");
40
+ const prompt = getPromptApi(ctx);
41
+ if (!prompt) {
42
+ console.warn("[Astrocode] Injection skipped: ctx.client.session.prompt unavailable");
43
+ return;
44
+ }
45
+ const maxAttempts = 3;
46
+ let attempt = 0;
47
+ while (attempt < maxAttempts) {
48
+ attempt += 1;
49
+ try {
50
+ await prompt({
51
+ path: { id: sessionId },
52
+ body: {
53
+ parts: [{ type: "text", text: prefixedText }],
54
+ agent,
55
+ },
56
+ });
18
57
  return;
19
58
  }
20
- if (!ctx?.client?.session?.prompt) {
21
- console.warn("[Astrocode] Skipping injection: API not available (ctx.client.session.prompt)");
22
- return;
59
+ catch (err) {
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ const isLast = attempt >= maxAttempts;
62
+ if (isLast) {
63
+ console.warn(`[Astrocode] Injection failed (final): ${msg}`);
64
+ return;
65
+ }
66
+ // Exponential backoff + jitter
67
+ const base = 150 * Math.pow(2, attempt - 1); // 150, 300, 600
68
+ const jitter = Math.floor(Math.random() * 120);
69
+ await sleep(base + jitter);
23
70
  }
24
- await ctx.client.session.prompt({
25
- path: { id: sessionId },
26
- body: {
27
- parts: [{ type: "text", text: prefixedText }],
28
- // Pass agent context for systems that support it
29
- agent: agent,
30
- },
31
- });
32
71
  }
33
- catch (error) {
34
- console.warn(`[Astrocode] Injection failed: ${error}`);
72
+ }
73
+ async function runWorkerLoop() {
74
+ if (workerRunning)
75
+ return;
76
+ workerRunning = true;
77
+ try {
78
+ // Drain sequentially to preserve ordering
79
+ while (injectionQueue.length > 0) {
80
+ const item = injectionQueue.shift();
81
+ if (!item)
82
+ continue;
83
+ await sendWithRetries(item);
84
+ }
35
85
  }
36
86
  finally {
37
- isInjecting = false;
38
- // Process next item immediately
39
- if (injectionQueue.length > 0) {
40
- setImmediate(processQueue);
41
- }
87
+ workerRunning = false;
88
+ resolveDrainWaitersIfIdle();
42
89
  }
43
90
  }
44
- export async function injectChatPrompt(opts) {
91
+ /**
92
+ * Enqueue an injection and ensure the worker is running.
93
+ * Does NOT wait for delivery — use `flushChatPrompts()` to wait.
94
+ */
95
+ export function enqueueChatPrompt(opts) {
45
96
  injectionQueue.push(opts);
46
- processQueue();
97
+ // Kick worker
98
+ void runWorkerLoop();
99
+ }
100
+ /**
101
+ * Wait until all queued injections have been processed (sent or exhausted retries).
102
+ */
103
+ export function flushChatPrompts() {
104
+ if (!workerRunning && injectionQueue.length === 0)
105
+ return Promise.resolve();
106
+ return new Promise((resolve) => {
107
+ drainWaiters.push(resolve);
108
+ // Ensure worker is running (in case someone enqueued without kick)
109
+ void runWorkerLoop();
110
+ });
111
+ }
112
+ /**
113
+ * Deterministic helper: enqueue + flush (recommended for stage boundaries).
114
+ */
115
+ export async function injectChatPrompt(opts) {
116
+ enqueueChatPrompt(opts);
117
+ await flushChatPrompts();
47
118
  }
@@ -1,6 +1,7 @@
1
1
  import type { AstrocodeConfig } from "../config/schema";
2
2
  import type { SqliteDb } from "../state/db";
3
3
  import type { RunRow, StageKey, StageRunRow, StoryRow } from "../state/types";
4
+ import type { ToastOptions } from "../ui/toasts";
4
5
  export declare const EVENT_TYPES: {
5
6
  readonly RUN_STARTED: "run.started";
6
7
  readonly RUN_COMPLETED: "run.completed";
@@ -10,23 +11,28 @@ export declare const EVENT_TYPES: {
10
11
  readonly STAGE_STARTED: "stage.started";
11
12
  readonly WORKFLOW_PROCEED: "workflow.proceed";
12
13
  };
14
+ /**
15
+ * UI HOOKS
16
+ * --------
17
+ * This workflow module is DB-first. UI emission is optional and happens AFTER the DB tx commits.
18
+ *
19
+ * Contract:
20
+ * - If you pass ui.ctx + ui.sessionId, we will inject a visible chat message deterministically.
21
+ * - If you pass ui.toast, we will also toast (throttling is handled by the toast manager).
22
+ */
23
+ export type WorkflowUi = {
24
+ ctx: any;
25
+ sessionId: string;
26
+ agentName?: string;
27
+ toast?: (t: ToastOptions) => Promise<void>;
28
+ };
13
29
  /**
14
30
  * PLANNING-FIRST REDESIGN
15
31
  * ----------------------
16
- * Old behavior: mutate an approved story into a planning/decomposition instruction.
17
- * New behavior: NEVER mutate story title/body.
18
- *
19
- * Planning-first is a run-scoped directive stored in `injects` (scope = run:<run_id>).
20
- *
21
- * Deterministic trigger (config-driven):
22
- * - config.workflow.genesis_planning:
23
- * - "off" => never attach directive
24
- * - "first_story_only"=> attach only when story_key === "S-0001"
25
- * - "always" => attach for every run
26
- *
27
- * Contract: DB is already initialized before workflow is used:
28
- * - schema tables exist
29
- * - repo_state singleton row (id=1) exists
32
+ * - Never mutate story title/body.
33
+ * - Planning-first becomes a run-scoped inject stored in `injects` (scope = run:<run_id>).
34
+ * - Trigger is deterministic via config.workflow.genesis_planning:
35
+ * - "off" | "first_story_only" | "always"
30
36
  */
31
37
  export type NextAction = {
32
38
  kind: "idle";
@@ -61,10 +67,20 @@ export declare function decideNextAction(db: SqliteDb, config: AstrocodeConfig):
61
67
  export declare function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): {
62
68
  run_id: string;
63
69
  };
70
+ /**
71
+ * STAGE MOVEMENT (START) — now async so UI injection is deterministic.
72
+ */
64
73
  export declare function startStage(db: SqliteDb, runId: string, stageKey: StageKey, meta?: {
65
74
  subagent_type?: string;
66
75
  subagent_session_id?: string;
67
- }): void;
68
- export declare function completeRun(db: SqliteDb, runId: string): void;
69
- export declare function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string): void;
76
+ ui?: WorkflowUi;
77
+ }): Promise<void>;
78
+ /**
79
+ * STAGE CLOSED (RUN COMPLETED) — now async so UI injection is deterministic.
80
+ */
81
+ export declare function completeRun(db: SqliteDb, runId: string, ui?: WorkflowUi): Promise<void>;
82
+ /**
83
+ * STAGE CLOSED (RUN FAILED) — now async so UI injection is deterministic.
84
+ */
85
+ export declare function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string, ui?: WorkflowUi): Promise<void>;
70
86
  export declare function abortRun(db: SqliteDb, runId: string, reason: string): void;