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.
- package/dist/tools/injects.js +90 -26
- package/dist/ui/inject.d.ts +16 -2
- package/dist/ui/inject.js +104 -33
- package/dist/workflow/state-machine.d.ts +33 -17
- package/dist/workflow/state-machine.js +116 -40
- package/package.json +1 -1
- package/src/tools/injects.ts +147 -53
- package/src/ui/inject.ts +115 -41
- package/src/workflow/state-machine.ts +161 -67
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/tools/injects.ts
CHANGED
|
@@ -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 = [
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
28
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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 =
|
|
212
|
-
const types =
|
|
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
|
|
238
|
-
|
|
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
|
-
//
|
|
255
|
-
if (inject.expires_at
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
401
|
+
null,
|
|
402
|
+
2
|
|
403
|
+
);
|
|
310
404
|
},
|
|
311
405
|
});
|
|
312
406
|
}
|