astrocode-workflow 0.1.59 → 0.2.1

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.
@@ -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
  }