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.
@@ -3,6 +3,8 @@ import { nowISO } from "../shared/time";
3
3
  import { newEventId, newRunId, newStageRunId } from "../state/ids";
4
4
  import { warn } from "../shared/log";
5
5
  import { sha256Hex } from "../shared/hash";
6
+ import { SCHEMA_VERSION } from "../state/schema";
7
+ import { injectChatPrompt } from "../ui/inject";
6
8
  export const EVENT_TYPES = {
7
9
  RUN_STARTED: "run.started",
8
10
  RUN_COMPLETED: "run.completed",
@@ -12,6 +14,42 @@ export const EVENT_TYPES = {
12
14
  STAGE_STARTED: "stage.started",
13
15
  WORKFLOW_PROCEED: "workflow.proceed",
14
16
  };
17
+ async function emitUi(ui, text, toast) {
18
+ if (!ui)
19
+ return;
20
+ // Prefer toast (if provided) AND also inject chat (for audit trail / visibility).
21
+ // If you want toast-only, pass a toast function and omit ctx/sessionId.
22
+ if (toast && ui.toast) {
23
+ try {
24
+ await ui.toast(toast);
25
+ }
26
+ catch {
27
+ // non-fatal
28
+ }
29
+ }
30
+ try {
31
+ await injectChatPrompt({
32
+ ctx: ui.ctx,
33
+ sessionId: ui.sessionId,
34
+ text,
35
+ agent: ui.agentName ?? "Astro",
36
+ });
37
+ }
38
+ catch {
39
+ // non-fatal (workflow correctness is DB-based)
40
+ }
41
+ }
42
+ function tableExists(db, tableName) {
43
+ try {
44
+ const row = db
45
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
46
+ .get(tableName);
47
+ return row?.name === tableName;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
15
53
  export function getActiveRun(db) {
16
54
  const row = db
17
55
  .prepare("SELECT * FROM runs WHERE status = 'running' ORDER BY started_at DESC, created_at DESC LIMIT 1")
@@ -52,7 +90,7 @@ export function decideNextAction(db, config) {
52
90
  if (current.status === "failed") {
53
91
  return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
54
92
  }
55
- warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
93
+ warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.status });
56
94
  return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
57
95
  }
58
96
  function getPipelineFromConfig(config) {
@@ -66,8 +104,7 @@ function getGenesisPlanningMode(config) {
66
104
  const raw = config?.workflow?.genesis_planning;
67
105
  if (raw === "off" || raw === "first_story_only" || raw === "always")
68
106
  return raw;
69
- if (raw != null)
70
- warn(`Invalid workflow.genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
107
+ warn(`Invalid genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
71
108
  return "first_story_only";
72
109
  }
73
110
  function shouldAttachPlanningDirective(config, story) {
@@ -79,6 +116,8 @@ function shouldAttachPlanningDirective(config, story) {
79
116
  return story.story_key === "S-0001";
80
117
  }
81
118
  function attachRunPlanningDirective(db, runId, story, pipeline) {
119
+ if (!tableExists(db, "injects"))
120
+ return;
82
121
  const now = nowISO();
83
122
  const injectId = `inj_${runId}_genesis_plan`;
84
123
  const body = [
@@ -96,16 +135,8 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
96
135
  `- Pipeline: ${pipeline.join(" → ")}`,
97
136
  ``,
98
137
  ].join("\n");
99
- let hash = null;
138
+ const hash = sha256Hex(body);
100
139
  try {
101
- hash = sha256Hex(body);
102
- }
103
- catch {
104
- // Hash is optional; directive must never be blocked by hashing.
105
- hash = null;
106
- }
107
- try {
108
- // Do not clobber user edits. If it exists, we leave it.
109
140
  db.prepare(`
110
141
  INSERT OR IGNORE INTO injects (
111
142
  inject_id, type, title, body_md, tags_json, scope, source, priority,
@@ -118,26 +149,9 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
118
149
  db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED, JSON.stringify({ story_key: story.story_key, inject_id: injectId }), now);
119
150
  }
120
151
  catch (e) {
121
- // Helpful, never required for correctness.
122
152
  warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
123
153
  }
124
154
  }
125
- function updateRepoStateLastEvent(db, now, fields) {
126
- // Contract: repo_state row exists. If not, fail deterministically (don’t “bootstrap” here).
127
- const res = db
128
- .prepare(`
129
- UPDATE repo_state
130
- SET last_run_id = COALESCE(?, last_run_id),
131
- last_story_key = COALESCE(?, last_story_key),
132
- last_event_at = ?,
133
- updated_at = ?
134
- WHERE id = 1
135
- `)
136
- .run(fields.last_run_id ?? null, fields.last_story_key ?? null, now, now);
137
- if (!res || res.changes === 0) {
138
- throw new Error("repo_state missing (id=1). Database not initialized; run init must be performed before workflow.");
139
- }
140
- }
141
155
  export function createRunForStory(db, config, storyKey) {
142
156
  return withTx(db, () => {
143
157
  const story = getStory(db, storyKey);
@@ -158,12 +172,24 @@ export function createRunForStory(db, config, storyKey) {
158
172
  if (shouldAttachPlanningDirective(config, story)) {
159
173
  attachRunPlanningDirective(db, run_id, story, pipeline);
160
174
  }
161
- updateRepoStateLastEvent(db, now, { last_run_id: run_id, last_story_key: storyKey });
175
+ db.prepare(`
176
+ INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
177
+ VALUES (1, ?, ?, ?, ?, ?, ?)
178
+ ON CONFLICT(id) DO UPDATE SET
179
+ last_run_id=excluded.last_run_id,
180
+ last_story_key=excluded.last_story_key,
181
+ last_event_at=excluded.last_event_at,
182
+ updated_at=excluded.updated_at
183
+ `).run(SCHEMA_VERSION, now, now, run_id, storyKey, now);
162
184
  return { run_id };
163
185
  });
164
186
  }
165
- export function startStage(db, runId, stageKey, meta) {
166
- return withTx(db, () => {
187
+ /**
188
+ * STAGE MOVEMENT (START) now async so UI injection is deterministic.
189
+ */
190
+ export async function startStage(db, runId, stageKey, meta) {
191
+ // Do DB work inside tx, capture what we need for UI outside.
192
+ const payload = withTx(db, () => {
167
193
  const now = nowISO();
168
194
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
169
195
  if (!run)
@@ -178,11 +204,32 @@ export function startStage(db, runId, stageKey, meta) {
178
204
  db.prepare("UPDATE stage_runs SET status='running', started_at=?, updated_at=?, subagent_type=COALESCE(?, subagent_type), subagent_session_id=COALESCE(?, subagent_session_id) WHERE stage_run_id=?").run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
179
205
  db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
180
206
  db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
181
- updateRepoStateLastEvent(db, now, {});
207
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
208
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
209
+ return {
210
+ now,
211
+ story_key: run.story_key,
212
+ story_title: story?.title ?? "",
213
+ };
214
+ });
215
+ // Deterministic UI emission AFTER commit (never inside tx).
216
+ await emitUi(meta?.ui, [
217
+ `🟦 Stage started`,
218
+ `- Run: \`${runId}\``,
219
+ `- Stage: \`${stageKey}\``,
220
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
221
+ ].join("\n"), {
222
+ title: "Stage started",
223
+ message: `${stageKey} (${payload.story_key})`,
224
+ variant: "info",
225
+ durationMs: 2500,
182
226
  });
183
227
  }
184
- export function completeRun(db, runId) {
185
- return withTx(db, () => {
228
+ /**
229
+ * STAGE CLOSED (RUN COMPLETED) now async so UI injection is deterministic.
230
+ */
231
+ export async function completeRun(db, runId, ui) {
232
+ const payload = withTx(db, () => {
186
233
  const now = nowISO();
187
234
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
188
235
  if (!run)
@@ -196,11 +243,26 @@ export function completeRun(db, runId) {
196
243
  db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
197
244
  db.prepare("UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
198
245
  db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
199
- updateRepoStateLastEvent(db, now, {});
246
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
247
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
248
+ return { now, story_key: run.story_key, story_title: story?.title ?? "" };
249
+ });
250
+ await emitUi(ui, [
251
+ `✅ Run completed`,
252
+ `- Run: \`${runId}\``,
253
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
254
+ ].join("\n"), {
255
+ title: "Run completed",
256
+ message: `${payload.story_key} — done`,
257
+ variant: "success",
258
+ durationMs: 3000,
200
259
  });
201
260
  }
202
- export function failRun(db, runId, stageKey, errorText) {
203
- return withTx(db, () => {
261
+ /**
262
+ * STAGE CLOSED (RUN FAILED) now async so UI injection is deterministic.
263
+ */
264
+ export async function failRun(db, runId, stageKey, errorText, ui) {
265
+ const payload = withTx(db, () => {
204
266
  const now = nowISO();
205
267
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
206
268
  if (!run)
@@ -208,7 +270,21 @@ export function failRun(db, runId, stageKey, errorText) {
208
270
  db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
209
271
  db.prepare("UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
210
272
  db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
211
- updateRepoStateLastEvent(db, now, {});
273
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
274
+ const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
275
+ return { now, story_key: run.story_key, story_title: story?.title ?? "" };
276
+ });
277
+ await emitUi(ui, [
278
+ `⛔ Run failed`,
279
+ `- Run: \`${runId}\``,
280
+ `- Stage: \`${stageKey}\``,
281
+ `- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
282
+ `- Error: ${errorText}`,
283
+ ].join("\n"), {
284
+ title: "Run failed",
285
+ message: `${stageKey}: ${errorText}`,
286
+ variant: "error",
287
+ durationMs: 4500,
212
288
  });
213
289
  }
214
290
  export function abortRun(db, runId, reason) {
@@ -220,6 +296,6 @@ export function abortRun(db, runId, reason) {
220
296
  db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
221
297
  db.prepare("UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
222
298
  db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
223
- updateRepoStateLastEvent(db, now, {});
299
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
224
300
  });
225
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.1.59",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -5,34 +5,51 @@ import { withTx } from "../state/db";
5
5
  import { nowISO } from "../shared/time";
6
6
  import { sha256Hex } from "../shared/hash";
7
7
 
8
- const VALID_INJECT_TYPES = ['note', 'policy', 'reminder', 'debug'] as const;
9
- type InjectType = typeof VALID_INJECT_TYPES[number];
8
+ const VALID_INJECT_TYPES = ["note", "policy", "reminder", "debug"] as const;
9
+ type InjectType = (typeof VALID_INJECT_TYPES)[number];
10
10
 
11
11
  function validateInjectType(type: string): InjectType {
12
12
  if (!VALID_INJECT_TYPES.includes(type as InjectType)) {
13
- throw new Error(`Invalid inject type "${type}". Must be one of: ${VALID_INJECT_TYPES.join(', ')}`);
13
+ throw new Error(`Invalid inject type "${type}". Must be one of: ${VALID_INJECT_TYPES.join(", ")}`);
14
14
  }
15
15
  return type as InjectType;
16
16
  }
17
17
 
18
- function validateTimestamp(timestamp: string | null): string | null {
18
+ function validateTimestamp(timestamp: string | null | undefined): string | null {
19
19
  if (!timestamp) return null;
20
20
 
21
- // Check if it's a valid ISO 8601 timestamp with Z suffix
21
+ // Strict ISO 8601 UTC with Z suffix, sortable as string.
22
22
  const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
23
23
  if (!isoRegex.test(timestamp)) {
24
- throw new Error(`Invalid timestamp format. Expected ISO 8601 UTC with Z suffix (e.g., "2026-01-23T13:05:19.000Z"), got: "${timestamp}"`);
24
+ throw new Error(
25
+ `Invalid timestamp format. Expected ISO 8601 UTC with Z suffix (e.g., "2026-01-23T13:05:19.000Z"), got: "${timestamp}"`
26
+ );
25
27
  }
26
28
 
27
- // Additional validation: ensure it's parseable and represents a valid date
28
- const parsed = new Date(timestamp);
29
- if (isNaN(parsed.getTime())) {
29
+ const parsed = Date.parse(timestamp);
30
+ if (!Number.isFinite(parsed)) {
30
31
  throw new Error(`Invalid timestamp: "${timestamp}" does not represent a valid date`);
31
32
  }
32
33
 
33
34
  return timestamp;
34
35
  }
35
36
 
37
+ function parseJsonStringArray(name: string, raw: string): string[] {
38
+ let v: unknown;
39
+ try {
40
+ v = JSON.parse(raw);
41
+ } catch (e) {
42
+ const msg = e instanceof Error ? e.message : String(e);
43
+ throw new Error(`${name} must be valid JSON. Parse error: ${msg}`);
44
+ }
45
+
46
+ if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) {
47
+ throw new Error(`${name} must be a JSON array of strings`);
48
+ }
49
+
50
+ return v as string[];
51
+ }
52
+
36
53
  function newInjectId(): string {
37
54
  return `inj_${Date.now()}_${Math.random().toString(16).slice(2)}`;
38
55
  }
@@ -58,17 +75,36 @@ export function createAstroInjectPutTool(opts: { ctx: any; config: AstrocodeConf
58
75
  const now = nowISO();
59
76
  const sha = sha256Hex(body_md);
60
77
 
61
- // Validate inputs
62
78
  const validatedType = validateInjectType(type);
63
79
  const validatedExpiresAt = validateTimestamp(expires_at);
64
80
 
81
+ // Ensure tags_json is at least valid JSON (we do not enforce schema here beyond validity).
82
+ try {
83
+ JSON.parse(tags_json);
84
+ } catch (e) {
85
+ const msg = e instanceof Error ? e.message : String(e);
86
+ throw new Error(`tags_json must be valid JSON. Parse error: ${msg}`);
87
+ }
88
+
65
89
  return withTx(db, () => {
66
90
  const existing = db.prepare("SELECT inject_id FROM injects WHERE inject_id=?").get(id) as any;
67
91
 
68
92
  if (existing) {
69
- // Use INSERT ... ON CONFLICT for atomic updates
70
93
  db.prepare(`
71
- INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at)
94
+ INSERT INTO injects (
95
+ inject_id,
96
+ type,
97
+ title,
98
+ body_md,
99
+ tags_json,
100
+ scope,
101
+ source,
102
+ priority,
103
+ expires_at,
104
+ sha256,
105
+ created_at,
106
+ updated_at
107
+ )
72
108
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
109
  ON CONFLICT(inject_id) DO UPDATE SET
74
110
  type=excluded.type,
@@ -81,7 +117,8 @@ export function createAstroInjectPutTool(opts: { ctx: any; config: AstrocodeConf
81
117
  expires_at=excluded.expires_at,
82
118
  sha256=excluded.sha256,
83
119
  updated_at=excluded.updated_at
84
- `).run(id, type, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
120
+ `).run(id, validatedType, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
121
+
85
122
  return `✅ Updated inject ${id}: ${title}`;
86
123
  }
87
124
 
@@ -108,9 +145,27 @@ export function createAstroInjectListTool(opts: { ctx: any; config: AstrocodeCon
108
145
  execute: async ({ scope, type, limit }) => {
109
146
  const where: string[] = [];
110
147
  const params: any[] = [];
111
- if (scope) { where.push("scope = ?"); params.push(scope); }
112
- if (type) { where.push("type = ?"); params.push(type); }
113
- 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 ?`;
148
+
149
+ if (scope) {
150
+ where.push("scope = ?");
151
+ params.push(scope);
152
+ }
153
+
154
+ if (type) {
155
+ // Keep list tool permissive (debugging), but still prevents obvious garbage if used.
156
+ validateInjectType(type);
157
+ where.push("type = ?");
158
+ params.push(type);
159
+ }
160
+
161
+ const sql = `
162
+ SELECT inject_id, type, title, scope, priority, created_at, updated_at
163
+ FROM injects
164
+ ${where.length ? "WHERE " + where.join(" AND ") : ""}
165
+ ORDER BY priority DESC, updated_at DESC
166
+ LIMIT ?
167
+ `;
168
+
114
169
  const rows = db.prepare(sql).all(...params, limit) as any[];
115
170
  return JSON.stringify(rows, null, 2);
116
171
  },
@@ -147,8 +202,20 @@ export function createAstroInjectSearchTool(opts: { ctx: any; config: AstrocodeC
147
202
  const like = `%${q}%`;
148
203
  const where: string[] = ["(title LIKE ? OR body_md LIKE ? OR tags_json LIKE ?)"];
149
204
  const params: any[] = [like, like, like];
150
- if (scope) { where.push("scope = ?"); params.push(scope); }
151
- 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 ?`;
205
+
206
+ if (scope) {
207
+ where.push("scope = ?");
208
+ params.push(scope);
209
+ }
210
+
211
+ const sql = `
212
+ SELECT inject_id, type, title, scope, priority, updated_at
213
+ FROM injects
214
+ WHERE ${where.join(" AND ")}
215
+ ORDER BY priority DESC, updated_at DESC
216
+ LIMIT ?
217
+ `;
218
+
152
219
  const rows = db.prepare(sql).all(...params, limit) as any[];
153
220
  return JSON.stringify(rows, null, 2);
154
221
  },
@@ -170,15 +237,25 @@ export type InjectRow = {
170
237
  updated_at: string;
171
238
  };
172
239
 
173
- export function selectEligibleInjects(db: SqliteDb, opts: {
174
- nowIso: string;
175
- scopeAllowlist: string[];
176
- typeAllowlist: string[];
177
- limit?: number;
178
- }): InjectRow[] {
240
+ export function selectEligibleInjects(
241
+ db: SqliteDb,
242
+ opts: {
243
+ nowIso: string;
244
+ scopeAllowlist: string[];
245
+ typeAllowlist: string[];
246
+ limit?: number;
247
+ }
248
+ ): InjectRow[] {
179
249
  const { nowIso, scopeAllowlist, typeAllowlist, limit = 50 } = opts;
180
250
 
181
- // Build placeholders safely
251
+ if (!Array.isArray(scopeAllowlist) || scopeAllowlist.length === 0) {
252
+ throw new Error("selectEligibleInjects: scopeAllowlist must be a non-empty array");
253
+ }
254
+ if (!Array.isArray(typeAllowlist) || typeAllowlist.length === 0) {
255
+ throw new Error("selectEligibleInjects: typeAllowlist must be a non-empty array");
256
+ }
257
+
258
+ // Build placeholders safely (guaranteed non-empty).
182
259
  const scopeQs = scopeAllowlist.map(() => "?").join(", ");
183
260
  const typeQs = typeAllowlist.map(() => "?").join(", ");
184
261
 
@@ -208,8 +285,11 @@ export function createAstroInjectEligibleTool(opts: { ctx: any; config: Astrocod
208
285
  },
209
286
  execute: async ({ scopes_json, types_json, limit }) => {
210
287
  const now = nowISO();
211
- const scopes = JSON.parse(scopes_json) as string[];
212
- const types = JSON.parse(types_json) as string[];
288
+ const scopes = parseJsonStringArray("scopes_json", scopes_json);
289
+ const types = parseJsonStringArray("types_json", types_json);
290
+
291
+ // Validate types against the known set to keep selection sane.
292
+ for (const t of types) validateInjectType(t);
213
293
 
214
294
  const rows = selectEligibleInjects(db, {
215
295
  nowIso: now,
@@ -234,10 +314,13 @@ export function createAstroInjectDebugDueTool(opts: { ctx: any; config: Astrocod
234
314
  },
235
315
  execute: async ({ scopes_json, types_json }) => {
236
316
  const now = nowISO();
237
- const scopes = JSON.parse(scopes_json) as string[];
238
- const types = JSON.parse(types_json) as string[];
317
+ const nowMs = Date.parse(now);
318
+
319
+ const scopes = parseJsonStringArray("scopes_json", scopes_json);
320
+ const types = parseJsonStringArray("types_json", types_json);
321
+
322
+ for (const t of types) validateInjectType(t);
239
323
 
240
- // Get ALL injects to analyze filtering
241
324
  const allInjects = db.prepare("SELECT * FROM injects").all() as any[];
242
325
 
243
326
  let total = allInjects.length;
@@ -245,25 +328,31 @@ export function createAstroInjectDebugDueTool(opts: { ctx: any; config: Astrocod
245
328
  let skippedExpired = 0;
246
329
  let skippedScope = 0;
247
330
  let skippedType = 0;
331
+ let skippedUnparseableExpiresAt = 0;
332
+
248
333
  const excludedReasons: any[] = [];
249
334
  const selectedInjects: any[] = [];
250
335
 
251
336
  for (const inject of allInjects) {
252
337
  const reasons: string[] = [];
253
338
 
254
- // Check expiration
255
- if (inject.expires_at && inject.expires_at <= now) {
256
- reasons.push("expired");
257
- skippedExpired++;
339
+ // Expiration: parse to ms for correctness across legacy rows.
340
+ if (inject.expires_at) {
341
+ const expMs = Date.parse(String(inject.expires_at));
342
+ if (!Number.isFinite(expMs)) {
343
+ reasons.push("expires_at_unparseable");
344
+ skippedUnparseableExpiresAt++;
345
+ } else if (expMs <= nowMs) {
346
+ reasons.push("expired");
347
+ skippedExpired++;
348
+ }
258
349
  }
259
350
 
260
- // Check scope
261
351
  if (!scopes.includes(inject.scope)) {
262
352
  reasons.push("scope");
263
353
  skippedScope++;
264
354
  }
265
355
 
266
- // Check type
267
356
  if (!types.includes(inject.type)) {
268
357
  reasons.push("type");
269
358
  skippedType++;
@@ -273,7 +362,7 @@ export function createAstroInjectDebugDueTool(opts: { ctx: any; config: Astrocod
273
362
  excludedReasons.push({
274
363
  inject_id: inject.inject_id,
275
364
  title: inject.title,
276
- reasons: reasons,
365
+ reasons,
277
366
  scope: inject.scope,
278
367
  type: inject.type,
279
368
  expires_at: inject.expires_at,
@@ -290,23 +379,28 @@ export function createAstroInjectDebugDueTool(opts: { ctx: any; config: Astrocod
290
379
  }
291
380
  }
292
381
 
293
- return JSON.stringify({
294
- now,
295
- scopes_considered: scopes,
296
- types_considered: types,
297
- summary: {
298
- total_injects: total,
299
- selected_eligible: selected,
300
- excluded_total: total - selected,
301
- skipped_breakdown: {
302
- expired: skippedExpired,
303
- scope: skippedScope,
304
- type: skippedType,
305
- }
382
+ return JSON.stringify(
383
+ {
384
+ now,
385
+ scopes_considered: scopes,
386
+ types_considered: types,
387
+ summary: {
388
+ total_injects: total,
389
+ selected_eligible: selected,
390
+ excluded_total: total - selected,
391
+ skipped_breakdown: {
392
+ expired: skippedExpired,
393
+ expires_at_unparseable: skippedUnparseableExpiresAt,
394
+ scope: skippedScope,
395
+ type: skippedType,
396
+ },
397
+ },
398
+ selected_injects: selectedInjects,
399
+ excluded_injects: excludedReasons,
306
400
  },
307
- selected_injects: selectedInjects,
308
- excluded_injects: excludedReasons,
309
- }, null, 2);
401
+ null,
402
+ 2
403
+ );
310
404
  },
311
405
  });
312
406
  }