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.
@@ -4,7 +4,7 @@ export declare function openSqlite(dbPath: string, opts?: {
4
4
  busyTimeoutMs?: number;
5
5
  }): SqliteDb;
6
6
  export declare function configurePragmas(db: SqliteDb, pragmas: Record<string, any>): void;
7
- /** BEGIN IMMEDIATE transaction helper. */
7
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
8
8
  export declare function withTx<T>(db: SqliteDb, fn: () => T, opts?: {
9
9
  require?: boolean;
10
10
  }): T;
package/dist/state/db.js CHANGED
@@ -46,7 +46,30 @@ export function configurePragmas(db, pragmas) {
46
46
  if (pragmas.temp_store)
47
47
  db.pragma(`temp_store = ${pragmas.temp_store}`);
48
48
  }
49
- /** BEGIN IMMEDIATE transaction helper. */
49
+ /**
50
+ * Re-entrant transaction helper.
51
+ *
52
+ * SQLite rejects BEGIN inside BEGIN. We use:
53
+ * - depth=0: BEGIN IMMEDIATE ... COMMIT/ROLLBACK
54
+ * - depth>0: SAVEPOINT sp_n ... RELEASE / ROLLBACK TO + RELEASE
55
+ *
56
+ * This allows callers to safely nest withTx across layers (tools -> workflow -> state machine)
57
+ * without "cannot start a transaction within a transaction".
58
+ */
59
+ const TX_DEPTH = new WeakMap();
60
+ function getDepth(db) {
61
+ return TX_DEPTH.get(db) ?? 0;
62
+ }
63
+ function setDepth(db, depth) {
64
+ if (depth <= 0)
65
+ TX_DEPTH.delete(db);
66
+ else
67
+ TX_DEPTH.set(db, depth);
68
+ }
69
+ function savepointName(depth) {
70
+ return `sp_${depth}`;
71
+ }
72
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
50
73
  export function withTx(db, fn, opts) {
51
74
  const adapter = createDatabaseAdapter();
52
75
  const available = adapter.isAvailable();
@@ -55,21 +78,56 @@ export function withTx(db, fn, opts) {
55
78
  throw new Error("Database adapter unavailable; transaction required.");
56
79
  return fn();
57
80
  }
58
- db.exec("BEGIN IMMEDIATE");
81
+ const depth = getDepth(db);
82
+ if (depth === 0) {
83
+ db.exec("BEGIN IMMEDIATE");
84
+ setDepth(db, 1);
85
+ try {
86
+ const out = fn();
87
+ db.exec("COMMIT");
88
+ return out;
89
+ }
90
+ catch (e) {
91
+ try {
92
+ db.exec("ROLLBACK");
93
+ }
94
+ catch {
95
+ // ignore
96
+ }
97
+ throw e;
98
+ }
99
+ finally {
100
+ setDepth(db, 0);
101
+ }
102
+ }
103
+ // Nested: use SAVEPOINT
104
+ const nextDepth = depth + 1;
105
+ const sp = savepointName(nextDepth);
106
+ db.exec(`SAVEPOINT ${sp}`);
107
+ setDepth(db, nextDepth);
59
108
  try {
60
109
  const out = fn();
61
- db.exec("COMMIT");
110
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
62
111
  return out;
63
112
  }
64
113
  catch (e) {
65
114
  try {
66
- db.exec("ROLLBACK");
115
+ db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
116
+ }
117
+ catch {
118
+ // ignore
119
+ }
120
+ try {
121
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
67
122
  }
68
123
  catch {
69
124
  // ignore
70
125
  }
71
126
  throw e;
72
127
  }
128
+ finally {
129
+ setDepth(db, depth);
130
+ }
73
131
  }
74
132
  export function getSchemaVersion(db) {
75
133
  try {
@@ -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,
@@ -2,9 +2,9 @@ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
2
  import type { AstrocodeConfig } from "../config/schema";
3
3
  import type { SqliteDb } from "../state/db";
4
4
  import type { StageKey } from "../state/types";
5
+ import type { AgentConfig } from "@opencode-ai/sdk";
5
6
  export declare const STAGE_TO_AGENT_MAP: Record<string, string>;
6
7
  export declare function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string;
7
- import { AgentConfig } from "@opencode-ai/sdk";
8
8
  export declare function createAstroWorkflowProceedTool(opts: {
9
9
  ctx: any;
10
10
  config: AstrocodeConfig;