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.
- package/dist/state/db.d.ts +1 -1
- package/dist/state/db.js +62 -4
- package/dist/tools/injects.js +90 -26
- package/dist/tools/workflow.d.ts +1 -1
- package/dist/tools/workflow.js +106 -101
- package/dist/ui/inject.d.ts +7 -1
- package/dist/ui/inject.js +86 -38
- package/dist/workflow/state-machine.d.ts +25 -9
- package/dist/workflow/state-machine.js +97 -106
- package/package.json +1 -1
- package/src/state/db.ts +63 -4
- package/src/tools/injects.ts +147 -53
- package/src/tools/workflow.ts +147 -140
- package/src/ui/inject.ts +107 -40
- package/src/workflow/state-machine.ts +127 -137
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { withTx } from "../state/db";
|
|
2
1
|
import { nowISO } from "../shared/time";
|
|
3
2
|
import { newEventId, newRunId, newStageRunId } from "../state/ids";
|
|
4
3
|
import { warn } from "../shared/log";
|
|
5
4
|
import { sha256Hex } from "../shared/hash";
|
|
5
|
+
import { SCHEMA_VERSION } from "../state/schema";
|
|
6
6
|
export const EVENT_TYPES = {
|
|
7
7
|
RUN_STARTED: "run.started",
|
|
8
8
|
RUN_COMPLETED: "run.completed",
|
|
@@ -12,6 +12,17 @@ export const EVENT_TYPES = {
|
|
|
12
12
|
STAGE_STARTED: "stage.started",
|
|
13
13
|
WORKFLOW_PROCEED: "workflow.proceed",
|
|
14
14
|
};
|
|
15
|
+
function tableExists(db, tableName) {
|
|
16
|
+
try {
|
|
17
|
+
const row = db
|
|
18
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
|
19
|
+
.get(tableName);
|
|
20
|
+
return row?.name === tableName;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
export function getActiveRun(db) {
|
|
16
27
|
const row = db
|
|
17
28
|
.prepare("SELECT * FROM runs WHERE status = 'running' ORDER BY started_at DESC, created_at DESC LIMIT 1")
|
|
@@ -66,8 +77,7 @@ function getGenesisPlanningMode(config) {
|
|
|
66
77
|
const raw = config?.workflow?.genesis_planning;
|
|
67
78
|
if (raw === "off" || raw === "first_story_only" || raw === "always")
|
|
68
79
|
return raw;
|
|
69
|
-
|
|
70
|
-
warn(`Invalid workflow.genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
|
|
80
|
+
warn(`Invalid genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
|
|
71
81
|
return "first_story_only";
|
|
72
82
|
}
|
|
73
83
|
function shouldAttachPlanningDirective(config, story) {
|
|
@@ -79,6 +89,8 @@ function shouldAttachPlanningDirective(config, story) {
|
|
|
79
89
|
return story.story_key === "S-0001";
|
|
80
90
|
}
|
|
81
91
|
function attachRunPlanningDirective(db, runId, story, pipeline) {
|
|
92
|
+
if (!tableExists(db, "injects"))
|
|
93
|
+
return;
|
|
82
94
|
const now = nowISO();
|
|
83
95
|
const injectId = `inj_${runId}_genesis_plan`;
|
|
84
96
|
const body = [
|
|
@@ -96,16 +108,8 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
|
|
|
96
108
|
`- Pipeline: ${pipeline.join(" → ")}`,
|
|
97
109
|
``,
|
|
98
110
|
].join("\n");
|
|
99
|
-
|
|
111
|
+
const hash = sha256Hex(body);
|
|
100
112
|
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
113
|
db.prepare(`
|
|
110
114
|
INSERT OR IGNORE INTO injects (
|
|
111
115
|
inject_id, type, title, body_md, tags_json, scope, source, priority,
|
|
@@ -118,108 +122,95 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
|
|
|
118
122
|
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
123
|
}
|
|
120
124
|
catch (e) {
|
|
121
|
-
// Helpful, never required for correctness.
|
|
122
125
|
warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
|
|
123
126
|
}
|
|
124
127
|
}
|
|
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
128
|
export function createRunForStory(db, config, storyKey) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
|
|
156
|
-
});
|
|
157
|
-
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
|
|
158
|
-
if (shouldAttachPlanningDirective(config, story)) {
|
|
159
|
-
attachRunPlanningDirective(db, run_id, story, pipeline);
|
|
160
|
-
}
|
|
161
|
-
updateRepoStateLastEvent(db, now, { last_run_id: run_id, last_story_key: storyKey });
|
|
162
|
-
return { run_id };
|
|
129
|
+
const story = getStory(db, storyKey);
|
|
130
|
+
if (!story)
|
|
131
|
+
throw new Error(`Story not found: ${storyKey}`);
|
|
132
|
+
if (story.state !== "approved")
|
|
133
|
+
throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
|
|
134
|
+
const run_id = newRunId();
|
|
135
|
+
const now = nowISO();
|
|
136
|
+
const pipeline = getPipelineFromConfig(config);
|
|
137
|
+
db.prepare("UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?").run(run_id, now, now, storyKey);
|
|
138
|
+
db.prepare("INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)").run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
|
|
139
|
+
const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
|
|
140
|
+
pipeline.forEach((stageKey, idx) => {
|
|
141
|
+
insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
|
|
163
142
|
});
|
|
143
|
+
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
|
|
144
|
+
if (shouldAttachPlanningDirective(config, story)) {
|
|
145
|
+
attachRunPlanningDirective(db, run_id, story, pipeline);
|
|
146
|
+
}
|
|
147
|
+
db.prepare(`
|
|
148
|
+
INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
|
|
149
|
+
VALUES (1, ?, ?, ?, ?, ?, ?)
|
|
150
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
151
|
+
last_run_id=excluded.last_run_id,
|
|
152
|
+
last_story_key=excluded.last_story_key,
|
|
153
|
+
last_event_at=excluded.last_event_at,
|
|
154
|
+
updated_at=excluded.updated_at
|
|
155
|
+
`).run(SCHEMA_VERSION, now, now, now, run_id, storyKey, now);
|
|
156
|
+
return { run_id };
|
|
164
157
|
}
|
|
165
|
-
export function startStage(db, runId, stageKey, meta) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
158
|
+
export function startStage(db, runId, stageKey, meta, emit) {
|
|
159
|
+
const now = nowISO();
|
|
160
|
+
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
161
|
+
if (!run)
|
|
162
|
+
throw new Error(`Run not found: ${runId}`);
|
|
163
|
+
if (run.status !== "running")
|
|
164
|
+
throw new Error(`Run is not running: ${runId} (status=${run.status})`);
|
|
165
|
+
const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
|
|
166
|
+
if (!stage)
|
|
167
|
+
throw new Error(`Stage run not found: ${runId}/${stageKey}`);
|
|
168
|
+
if (stage.status !== "pending")
|
|
169
|
+
throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
|
|
170
|
+
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);
|
|
171
|
+
db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
|
|
172
|
+
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);
|
|
173
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
174
|
+
// ✅ Explicit wiring point (requested): stage movement
|
|
175
|
+
emit?.({ kind: "stage_started", run_id: runId, stage_key: stageKey, agent_name: meta?.subagent_type });
|
|
183
176
|
}
|
|
184
|
-
export function completeRun(db, runId) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
});
|
|
177
|
+
export function completeRun(db, runId, emit) {
|
|
178
|
+
const now = nowISO();
|
|
179
|
+
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
180
|
+
if (!run)
|
|
181
|
+
throw new Error(`Run not found: ${runId}`);
|
|
182
|
+
if (run.status !== "running")
|
|
183
|
+
throw new Error(`Run not running: ${runId} (status=${run.status})`);
|
|
184
|
+
const stageRuns = getStageRuns(db, runId);
|
|
185
|
+
const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
|
|
186
|
+
if (incomplete)
|
|
187
|
+
throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
|
|
188
|
+
db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
|
|
189
|
+
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);
|
|
190
|
+
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);
|
|
191
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
192
|
+
// ✅ Explicit wiring point (requested): run closed success
|
|
193
|
+
emit?.({ kind: "run_completed", run_id: runId, story_key: run.story_key });
|
|
201
194
|
}
|
|
202
|
-
export function failRun(db, runId, stageKey, errorText) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
});
|
|
195
|
+
export function failRun(db, runId, stageKey, errorText, emit) {
|
|
196
|
+
const now = nowISO();
|
|
197
|
+
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
198
|
+
if (!run)
|
|
199
|
+
throw new Error(`Run not found: ${runId}`);
|
|
200
|
+
db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
|
|
201
|
+
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);
|
|
202
|
+
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);
|
|
203
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
204
|
+
// ✅ Explicit wiring point (requested): run closed failure
|
|
205
|
+
emit?.({ kind: "run_failed", run_id: runId, story_key: run.story_key, stage_key: stageKey, error_text: errorText });
|
|
213
206
|
}
|
|
214
207
|
export function abortRun(db, runId, reason) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
updateRepoStateLastEvent(db, now, {});
|
|
224
|
-
});
|
|
208
|
+
const now = nowISO();
|
|
209
|
+
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
210
|
+
if (!run)
|
|
211
|
+
throw new Error(`Run not found: ${runId}`);
|
|
212
|
+
db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
|
|
213
|
+
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);
|
|
214
|
+
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);
|
|
215
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
225
216
|
}
|
package/package.json
CHANGED
package/src/state/db.ts
CHANGED
|
@@ -48,7 +48,32 @@ export function configurePragmas(db: SqliteDb, pragmas: Record<string, any>) {
|
|
|
48
48
|
if (pragmas.temp_store) db.pragma(`temp_store = ${pragmas.temp_store}`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Re-entrant transaction helper.
|
|
53
|
+
*
|
|
54
|
+
* SQLite rejects BEGIN inside BEGIN. We use:
|
|
55
|
+
* - depth=0: BEGIN IMMEDIATE ... COMMIT/ROLLBACK
|
|
56
|
+
* - depth>0: SAVEPOINT sp_n ... RELEASE / ROLLBACK TO + RELEASE
|
|
57
|
+
*
|
|
58
|
+
* This allows callers to safely nest withTx across layers (tools -> workflow -> state machine)
|
|
59
|
+
* without "cannot start a transaction within a transaction".
|
|
60
|
+
*/
|
|
61
|
+
const TX_DEPTH = new WeakMap<object, number>();
|
|
62
|
+
|
|
63
|
+
function getDepth(db: SqliteDb): number {
|
|
64
|
+
return TX_DEPTH.get(db as any) ?? 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function setDepth(db: SqliteDb, depth: number) {
|
|
68
|
+
if (depth <= 0) TX_DEPTH.delete(db as any);
|
|
69
|
+
else TX_DEPTH.set(db as any, depth);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function savepointName(depth: number): string {
|
|
73
|
+
return `sp_${depth}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** BEGIN IMMEDIATE transaction helper (re-entrant). */
|
|
52
77
|
export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean }): T {
|
|
53
78
|
const adapter = createDatabaseAdapter();
|
|
54
79
|
const available = adapter.isAvailable();
|
|
@@ -58,18 +83,52 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
|
|
|
58
83
|
return fn();
|
|
59
84
|
}
|
|
60
85
|
|
|
61
|
-
db
|
|
86
|
+
const depth = getDepth(db);
|
|
87
|
+
|
|
88
|
+
if (depth === 0) {
|
|
89
|
+
db.exec("BEGIN IMMEDIATE");
|
|
90
|
+
setDepth(db, 1);
|
|
91
|
+
try {
|
|
92
|
+
const out = fn();
|
|
93
|
+
db.exec("COMMIT");
|
|
94
|
+
return out;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
try {
|
|
97
|
+
db.exec("ROLLBACK");
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
throw e;
|
|
102
|
+
} finally {
|
|
103
|
+
setDepth(db, 0);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Nested: use SAVEPOINT
|
|
108
|
+
const nextDepth = depth + 1;
|
|
109
|
+
const sp = savepointName(nextDepth);
|
|
110
|
+
|
|
111
|
+
db.exec(`SAVEPOINT ${sp}`);
|
|
112
|
+
setDepth(db, nextDepth);
|
|
113
|
+
|
|
62
114
|
try {
|
|
63
115
|
const out = fn();
|
|
64
|
-
db.exec(
|
|
116
|
+
db.exec(`RELEASE SAVEPOINT ${sp}`);
|
|
65
117
|
return out;
|
|
66
118
|
} catch (e) {
|
|
67
119
|
try {
|
|
68
|
-
db.exec(
|
|
120
|
+
db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
db.exec(`RELEASE SAVEPOINT ${sp}`);
|
|
69
126
|
} catch {
|
|
70
127
|
// ignore
|
|
71
128
|
}
|
|
72
129
|
throw e;
|
|
130
|
+
} finally {
|
|
131
|
+
setDepth(db, depth);
|
|
73
132
|
}
|
|
74
133
|
}
|
|
75
134
|
|