@ghostwater/soulforge 0.6.0 → 0.8.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/cli/cli.js +323 -117
- package/dist/cli/cli.js.map +1 -1
- package/dist/daemon/daemon.js +112 -6
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/daemon/runner.js +634 -64
- package/dist/daemon/runner.js.map +1 -1
- package/dist/db/database.d.ts +20 -10
- package/dist/db/database.js +182 -44
- package/dist/db/database.js.map +1 -1
- package/dist/executors/claude-code.d.ts +2 -0
- package/dist/executors/claude-code.js +38 -2
- package/dist/executors/claude-code.js.map +1 -1
- package/dist/executors/codex-cli.d.ts +2 -0
- package/dist/executors/codex-cli.js +37 -2
- package/dist/executors/codex-cli.js.map +1 -1
- package/dist/executors/codex.d.ts +1 -0
- package/dist/executors/codex.js +53 -0
- package/dist/executors/codex.js.map +1 -1
- package/dist/executors/openclaw.d.ts +1 -0
- package/dist/executors/openclaw.js +11 -0
- package/dist/executors/openclaw.js.map +1 -1
- package/dist/executors/self.d.ts +1 -0
- package/dist/executors/self.js +11 -0
- package/dist/executors/self.js.map +1 -1
- package/dist/executors/types.d.ts +4 -0
- package/dist/lib/worktree.d.ts +1 -1
- package/dist/lib/worktree.js +2 -1
- package/dist/lib/worktree.js.map +1 -1
- package/dist/workflow/discovery.d.ts +24 -0
- package/dist/workflow/discovery.js +120 -0
- package/dist/workflow/discovery.js.map +1 -0
- package/dist/workflow/gate-routing.d.ts +5 -0
- package/dist/workflow/gate-routing.js +44 -0
- package/dist/workflow/gate-routing.js.map +1 -0
- package/dist/workflow/parser.js +117 -5
- package/dist/workflow/parser.js.map +1 -1
- package/dist/workflow/schema-validator.d.ts +6 -0
- package/dist/workflow/schema-validator.js +120 -0
- package/dist/workflow/schema-validator.js.map +1 -0
- package/dist/workflow/template.d.ts +2 -1
- package/dist/workflow/template.js +13 -21
- package/dist/workflow/template.js.map +1 -1
- package/dist/workflow/types.d.ts +22 -2
- package/package.json +1 -1
- package/workflows/bugfix/workflow.yml +248 -40
- package/workflows/feature-dev/workflow.yml +252 -48
package/dist/cli/cli.js
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { execSync } from "node:child_process";
|
|
6
|
-
import crypto from "node:crypto";
|
|
7
5
|
import fs from "node:fs";
|
|
8
6
|
import path from "node:path";
|
|
9
|
-
import { createRun, getRun, listRuns, getStepsForRun,
|
|
7
|
+
import { createRun, getRun, listRuns, getStepsForRun, getLoopItemsForRun, getEvents, updateStepStatus, updateRunStatus, updateRunContext, advancePipeline, insertEvent, resetToStep, getDb, getDataDir, } from "../db/database.js";
|
|
10
8
|
import { loadWorkflowSpec } from "../workflow/parser.js";
|
|
9
|
+
import { validateOutput } from "../workflow/schema-validator.js";
|
|
10
|
+
import { getContextValue, parseGateLoopCounts, resolveGateRoute } from "../workflow/gate-routing.js";
|
|
11
|
+
import { findBuiltinWorkflowByName, findNamedWorkflow, getCustomWorkflowPath, getCustomWorkflowRoot, listDiscoveredWorkflows, resolveWorkflowInput, } from "../workflow/discovery.js";
|
|
11
12
|
import { listExecutors } from "../executors/registry.js";
|
|
12
13
|
import { isDaemonRunning, startDaemonBackground, stopDaemon } from "../daemon/daemon.js";
|
|
13
14
|
import { readRecentLogs } from "../lib/logger.js";
|
|
14
|
-
import { createWorktree, validateGitRepo } from "../lib/worktree.js";
|
|
15
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
16
|
const __dirname = dirname(__filename);
|
|
17
17
|
function getVersion() {
|
|
@@ -35,20 +35,28 @@ Usage:
|
|
|
35
35
|
soulforge run <workflow> "<task>" [options]
|
|
36
36
|
Start a workflow run
|
|
37
37
|
--var key=value Set workflow variable
|
|
38
|
-
--workdir <path> Work in an existing directory (
|
|
39
|
-
--
|
|
40
|
-
--
|
|
41
|
-
--callback-
|
|
38
|
+
--workdir <path> Work in an existing directory (required)
|
|
39
|
+
--keep-worktree Keep worktree metadata/files after run completion
|
|
40
|
+
--callback-url <url> Callback URL for step notifications
|
|
41
|
+
--callback-exec <command> Shell command callback for step notifications
|
|
42
42
|
--callback-headers <json> Headers for callback requests
|
|
43
43
|
--callback-body <json> Body template for callback requests
|
|
44
|
-
--no-callback Run without callbacks (
|
|
44
|
+
--no-callback Run without callbacks (disables URL + exec callbacks)
|
|
45
45
|
--executor <name> Override executor for all code steps (e.g., codex-cli)
|
|
46
|
+
--model <name> Override model for all code steps (e.g., gpt-4o)
|
|
47
|
+
soulforge workflow list List built-in and custom workflows
|
|
48
|
+
soulforge workflow show <name>
|
|
49
|
+
Show workflow YAML by name
|
|
50
|
+
soulforge workflow create <name> [--from <template>] [--force]
|
|
51
|
+
Create custom workflow in ~/.soulforge/workflows
|
|
46
52
|
soulforge status [<query>] Check run status (ID prefix or task substring)
|
|
47
53
|
soulforge runs List all runs
|
|
48
54
|
soulforge approve <run-id> [--message "..."]
|
|
49
55
|
Approve a checkpoint
|
|
50
56
|
soulforge reject <run-id> --reason "..."
|
|
51
57
|
Reject a checkpoint
|
|
58
|
+
soulforge complete --run-id <id> --step-id <id> --data '<json>'
|
|
59
|
+
Complete a step and persist output data
|
|
52
60
|
soulforge cancel <run-id> Cancel a running workflow
|
|
53
61
|
soulforge resume <run-id> Resume a failed run
|
|
54
62
|
|
|
@@ -61,6 +69,46 @@ Usage:
|
|
|
61
69
|
Environment:
|
|
62
70
|
SOULFORGE_DATA_DIR Data directory (default: ~/.soulforge)`);
|
|
63
71
|
}
|
|
72
|
+
function isPlainObject(value) {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
function formatDaemonStatusLine() {
|
|
76
|
+
const status = isDaemonRunning();
|
|
77
|
+
if (!status.running || !status.pid) {
|
|
78
|
+
return "Daemon: not running";
|
|
79
|
+
}
|
|
80
|
+
const heartbeatPath = path.join(getDataDir(), "heartbeat");
|
|
81
|
+
try {
|
|
82
|
+
const heartbeatRaw = readFileSync(heartbeatPath, "utf-8").trim();
|
|
83
|
+
const heartbeat = Number.parseInt(heartbeatRaw, 10);
|
|
84
|
+
if (!Number.isFinite(heartbeat)) {
|
|
85
|
+
throw new Error("Heartbeat is not numeric");
|
|
86
|
+
}
|
|
87
|
+
const ageMs = Math.max(0, Date.now() - heartbeat);
|
|
88
|
+
if (ageMs < 30_000) {
|
|
89
|
+
const ageSeconds = Math.floor(ageMs / 1000);
|
|
90
|
+
return `Daemon: running (PID ${status.pid}, last tick ${ageSeconds}s ago)`;
|
|
91
|
+
}
|
|
92
|
+
const ageMinutes = Math.max(1, Math.floor(ageMs / 60_000));
|
|
93
|
+
return `Daemon: ⚠️ running (PID ${status.pid}, last tick ${ageMinutes}m ago — may be hung)`;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return `Daemon: ⚠️ running (PID ${status.pid}, last tick unknown — may be hung)`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function isValidWorkflowName(name) {
|
|
100
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
|
|
101
|
+
}
|
|
102
|
+
function buildDefaultWorkflowTemplate(name) {
|
|
103
|
+
return `id: ${name}
|
|
104
|
+
name: ${name}
|
|
105
|
+
steps:
|
|
106
|
+
- id: implement
|
|
107
|
+
executor: codex
|
|
108
|
+
input: |
|
|
109
|
+
Implement the requested task.
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
64
112
|
async function main() {
|
|
65
113
|
const args = process.argv.slice(2);
|
|
66
114
|
const [group, action, ...rest] = args;
|
|
@@ -93,16 +141,83 @@ async function main() {
|
|
|
93
141
|
return;
|
|
94
142
|
}
|
|
95
143
|
if (action === "status") {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
console.log(formatDaemonStatusLine());
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
printUsage();
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
// ── Workflow commands ───────────────────────────────────────────
|
|
151
|
+
if (group === "workflow") {
|
|
152
|
+
if (action === "list") {
|
|
153
|
+
const workflows = await listDiscoveredWorkflows();
|
|
154
|
+
if (workflows.length === 0) {
|
|
155
|
+
console.log("No workflows found.");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
for (const workflow of workflows) {
|
|
159
|
+
console.log(`${workflow.name}\t${workflow.source}`);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (action === "show") {
|
|
164
|
+
const name = rest[0];
|
|
165
|
+
if (!name) {
|
|
166
|
+
console.error("Missing workflow name. Usage: soulforge workflow show <name>");
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
const workflow = await findNamedWorkflow(name);
|
|
170
|
+
if (!workflow) {
|
|
171
|
+
console.error(`Workflow not found by name: ${name}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
console.log(readFileSync(workflow.path, "utf-8"));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (action === "create") {
|
|
178
|
+
const name = rest[0];
|
|
179
|
+
if (!name) {
|
|
180
|
+
console.error("Missing workflow name. Usage: soulforge workflow create <name> [--from <template>] [--force]");
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
if (!isValidWorkflowName(name)) {
|
|
184
|
+
console.error(`Invalid workflow name "${name}". Use letters, numbers, dot, underscore, and hyphen only.`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
let fromTemplate;
|
|
188
|
+
let force = false;
|
|
189
|
+
for (let i = 1; i < rest.length; i += 1) {
|
|
190
|
+
if (rest[i] === "--from" && rest[i + 1]) {
|
|
191
|
+
fromTemplate = rest[i + 1];
|
|
192
|
+
i += 1;
|
|
193
|
+
}
|
|
194
|
+
else if (rest[i] === "--force") {
|
|
195
|
+
force = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const destinationPath = getCustomWorkflowPath(name);
|
|
199
|
+
if (fs.existsSync(destinationPath) && !force) {
|
|
200
|
+
console.error(`Workflow already exists: ${destinationPath}. Use --force to overwrite.`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
let templateContent;
|
|
204
|
+
if (fromTemplate) {
|
|
205
|
+
const sourceWorkflow = await findBuiltinWorkflowByName(fromTemplate);
|
|
206
|
+
if (!sourceWorkflow) {
|
|
207
|
+
console.error(`Built-in workflow not found: ${fromTemplate}`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
templateContent = readFileSync(sourceWorkflow.path, "utf-8");
|
|
99
211
|
}
|
|
100
212
|
else {
|
|
101
|
-
|
|
213
|
+
templateContent = buildDefaultWorkflowTemplate(name);
|
|
102
214
|
}
|
|
215
|
+
fs.mkdirSync(getCustomWorkflowRoot(), { recursive: true });
|
|
216
|
+
fs.writeFileSync(destinationPath, templateContent);
|
|
217
|
+
console.log(`Created workflow: ${destinationPath}`);
|
|
103
218
|
return;
|
|
104
219
|
}
|
|
105
|
-
|
|
220
|
+
console.error("Unknown workflow command. Use: list, show, create");
|
|
106
221
|
process.exit(1);
|
|
107
222
|
}
|
|
108
223
|
// ── Run command ─────────────────────────────────────────────────
|
|
@@ -119,13 +234,15 @@ async function main() {
|
|
|
119
234
|
// Parse flags (order-independent)
|
|
120
235
|
const vars = {};
|
|
121
236
|
let workdir;
|
|
122
|
-
let noWorktree = false;
|
|
123
|
-
let branchName;
|
|
124
237
|
let callbackUrl;
|
|
238
|
+
let callbackExec;
|
|
125
239
|
let callbackHeaders;
|
|
126
240
|
let callbackBodyTemplate;
|
|
127
241
|
let noCallback = false;
|
|
242
|
+
let keepWorktree = false;
|
|
128
243
|
let executorOverride;
|
|
244
|
+
let modelOverride;
|
|
245
|
+
let modelFlagSeen = false;
|
|
129
246
|
for (let i = 1; i < rest.length; i++) {
|
|
130
247
|
if (rest[i] === "--var" && rest[i + 1]) {
|
|
131
248
|
const [key, ...valParts] = rest[i + 1].split("=");
|
|
@@ -136,17 +253,14 @@ async function main() {
|
|
|
136
253
|
workdir = rest[i + 1];
|
|
137
254
|
i++;
|
|
138
255
|
}
|
|
139
|
-
else if (rest[i] === "--no-worktree") {
|
|
140
|
-
noWorktree = true;
|
|
141
|
-
}
|
|
142
|
-
else if (rest[i] === "--branch" && rest[i + 1]) {
|
|
143
|
-
branchName = rest[i + 1];
|
|
144
|
-
i++;
|
|
145
|
-
}
|
|
146
256
|
else if (rest[i] === "--callback-url" && rest[i + 1]) {
|
|
147
257
|
callbackUrl = rest[i + 1];
|
|
148
258
|
i++;
|
|
149
259
|
}
|
|
260
|
+
else if (rest[i] === "--callback-exec" && rest[i + 1]) {
|
|
261
|
+
callbackExec = rest[i + 1];
|
|
262
|
+
i++;
|
|
263
|
+
}
|
|
150
264
|
else if (rest[i] === "--callback-headers" && rest[i + 1]) {
|
|
151
265
|
try {
|
|
152
266
|
callbackHeaders = JSON.parse(rest[i + 1]);
|
|
@@ -170,36 +284,51 @@ async function main() {
|
|
|
170
284
|
else if (rest[i] === "--no-callback") {
|
|
171
285
|
noCallback = true;
|
|
172
286
|
}
|
|
287
|
+
else if (rest[i] === "--keep-worktree") {
|
|
288
|
+
keepWorktree = true;
|
|
289
|
+
}
|
|
173
290
|
else if (rest[i] === "--executor" && rest[i + 1]) {
|
|
174
291
|
executorOverride = rest[i + 1];
|
|
175
292
|
i++;
|
|
176
293
|
}
|
|
294
|
+
else if (rest[i] === "--model") {
|
|
295
|
+
modelFlagSeen = true;
|
|
296
|
+
modelOverride = rest[i + 1];
|
|
297
|
+
if (rest[i + 1] !== undefined) {
|
|
298
|
+
i++;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!workdir) {
|
|
303
|
+
if (vars.repo) {
|
|
304
|
+
console.error("Error: --workdir is required. Specify the project directory for this run. (Hint: --var repo is no longer supported, use --workdir instead)");
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
console.error("Error: --workdir is required. Specify the project directory for this run.");
|
|
308
|
+
}
|
|
309
|
+
process.exit(1);
|
|
177
310
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (workdir && repoPath) {
|
|
181
|
-
console.error("--workdir and --var repo=... are mutually exclusive. Use one or the other.");
|
|
311
|
+
if (vars.repo) {
|
|
312
|
+
console.error("Error: --var repo is no longer supported. Use --workdir instead.");
|
|
182
313
|
process.exit(1);
|
|
183
314
|
}
|
|
184
315
|
// Validate --workdir exists
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
process.exit(1);
|
|
194
|
-
}
|
|
316
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
317
|
+
if (!fs.existsSync(resolvedWorkdir)) {
|
|
318
|
+
console.error(`--workdir path does not exist: ${resolvedWorkdir}`);
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
if (!fs.statSync(resolvedWorkdir).isDirectory()) {
|
|
322
|
+
console.error(`--workdir path is not a directory: ${resolvedWorkdir}`);
|
|
323
|
+
process.exit(1);
|
|
195
324
|
}
|
|
196
325
|
// Validate callback flags after parsing (order-independent)
|
|
197
|
-
if (noCallback && callbackUrl) {
|
|
198
|
-
console.error("--no-callback
|
|
326
|
+
if (noCallback && (callbackUrl || callbackExec)) {
|
|
327
|
+
console.error("--no-callback is mutually exclusive with --callback-url and --callback-exec.");
|
|
199
328
|
process.exit(1);
|
|
200
329
|
}
|
|
201
|
-
if (!noCallback && !callbackUrl) {
|
|
202
|
-
console.error("Error: No callback configured. Use --callback-url
|
|
330
|
+
if (!noCallback && !callbackUrl && !callbackExec) {
|
|
331
|
+
console.error("Error: No callback configured. Use --callback-url and/or --callback-exec, or --no-callback to run without.");
|
|
203
332
|
process.exit(1);
|
|
204
333
|
}
|
|
205
334
|
let callbackConfig;
|
|
@@ -217,65 +346,29 @@ async function main() {
|
|
|
217
346
|
process.exit(1);
|
|
218
347
|
}
|
|
219
348
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (workdir) {
|
|
225
|
-
// --workdir provided: use it directly, no git operations
|
|
226
|
-
effectiveWorkdir = path.resolve(workdir);
|
|
227
|
-
}
|
|
228
|
-
else if (repoPath) {
|
|
229
|
-
const resolvedRepo = path.resolve(repoPath);
|
|
230
|
-
if (noWorktree) {
|
|
231
|
-
// --no-worktree: work directly in the repo, no worktree creation
|
|
232
|
-
effectiveWorkdir = resolvedRepo;
|
|
349
|
+
if (modelFlagSeen) {
|
|
350
|
+
if (modelOverride === undefined) {
|
|
351
|
+
console.error("Invalid --model value: missing model name.");
|
|
352
|
+
process.exit(1);
|
|
233
353
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
console.error(`Cannot auto-create worktree: ${resolvedRepo} is not a git repository. Use --workdir instead.`);
|
|
239
|
-
process.exit(1);
|
|
240
|
-
}
|
|
241
|
-
const shortId = crypto.randomUUID().slice(0, 8);
|
|
242
|
-
const branch = branchName || `soulforge/${shortId}`;
|
|
243
|
-
console.log(`Creating worktree with branch: ${branch}`);
|
|
244
|
-
try {
|
|
245
|
-
const result = createWorktree(resolvedRepo, branch, repoInfo);
|
|
246
|
-
worktreeMetadata = {
|
|
247
|
-
originalRepo: resolvedRepo,
|
|
248
|
-
worktreePath: result.worktreePath,
|
|
249
|
-
branch: result.branch,
|
|
250
|
-
};
|
|
251
|
-
effectiveWorkdir = result.worktreePath;
|
|
252
|
-
// Install npm dependencies if package.json exists
|
|
253
|
-
const packageJsonPath = path.join(result.worktreePath, 'package.json');
|
|
254
|
-
if (fs.existsSync(packageJsonPath)) {
|
|
255
|
-
console.log("Installing npm dependencies in worktree...");
|
|
256
|
-
try {
|
|
257
|
-
execSync('npm install', { cwd: result.worktreePath, stdio: 'inherit' });
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
console.warn("Warning: npm install failed in worktree");
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
console.log(`Worktree created at: ${result.worktreePath}`);
|
|
264
|
-
}
|
|
265
|
-
catch (err) {
|
|
266
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
267
|
-
process.exit(1);
|
|
268
|
-
}
|
|
354
|
+
const trimmedModel = modelOverride.trim();
|
|
355
|
+
if (trimmedModel.length === 0) {
|
|
356
|
+
console.error("Invalid --model value: model name cannot be empty.");
|
|
357
|
+
process.exit(1);
|
|
269
358
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
vars.workdir = effectiveWorkdir;
|
|
274
|
-
// Also keep repo pointing to the effective workdir for backwards compatibility
|
|
275
|
-
if (repoPath) {
|
|
276
|
-
vars.repo = effectiveWorkdir;
|
|
359
|
+
if (trimmedModel.startsWith("-")) {
|
|
360
|
+
console.error(`Invalid --model value "${modelOverride}": model name cannot start with '-'.`);
|
|
361
|
+
process.exit(1);
|
|
277
362
|
}
|
|
363
|
+
modelOverride = trimmedModel;
|
|
278
364
|
}
|
|
365
|
+
const workflowResolution = await resolveWorkflowInput(action);
|
|
366
|
+
const resolvedWorkflowPath = workflowResolution.path;
|
|
367
|
+
const spec = await loadWorkflowSpec(resolvedWorkflowPath);
|
|
368
|
+
// Determine the working directory for the run
|
|
369
|
+
const effectiveWorkdir = path.resolve(workdir);
|
|
370
|
+
// Set workdir variable for templates
|
|
371
|
+
vars.workdir = effectiveWorkdir;
|
|
279
372
|
// Merge vars into context
|
|
280
373
|
if (spec.context) {
|
|
281
374
|
Object.assign(spec.context, vars);
|
|
@@ -283,31 +376,106 @@ async function main() {
|
|
|
283
376
|
else {
|
|
284
377
|
spec.context = vars;
|
|
285
378
|
}
|
|
286
|
-
// Ensure daemon is running
|
|
287
|
-
|
|
288
|
-
if
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
// Give daemon a moment to start
|
|
292
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
293
|
-
}
|
|
294
|
-
const run = createRun(spec, action, task, callbackConfig, worktreeMetadata, executorOverride);
|
|
379
|
+
// Ensure daemon is running (idempotent singleton auto-start).
|
|
380
|
+
startDaemonBackground();
|
|
381
|
+
// Give daemon a moment to start if it was just spawned.
|
|
382
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
383
|
+
const run = createRun(spec, resolvedWorkflowPath, task, callbackConfig, undefined, executorOverride, modelOverride, keepWorktree, callbackExec);
|
|
295
384
|
insertEvent(run.id, "run_started", `Run started: "${task}"`);
|
|
296
385
|
console.log(`Run: ${run.id}`);
|
|
297
386
|
console.log(`Workflow: ${run.workflow_id}`);
|
|
298
387
|
console.log(`Task: ${task}`);
|
|
299
|
-
|
|
300
|
-
console.log(`Workdir: ${effectiveWorkdir}`);
|
|
301
|
-
}
|
|
388
|
+
console.log(`Workdir: ${effectiveWorkdir}`);
|
|
302
389
|
console.log(`Status: ${run.status}`);
|
|
303
390
|
console.log(`\nDaemon will pick up the first step shortly.`);
|
|
304
391
|
console.log(`Track progress: soulforge status ${run.id.slice(0, 8)}`);
|
|
305
392
|
return;
|
|
306
393
|
}
|
|
394
|
+
// ── Complete command ────────────────────────────────────────────
|
|
395
|
+
if (group === "complete") {
|
|
396
|
+
const completeArgs = [action, ...rest].filter((arg) => arg !== undefined);
|
|
397
|
+
let runId;
|
|
398
|
+
let stepId;
|
|
399
|
+
let dataRaw;
|
|
400
|
+
for (let i = 0; i < completeArgs.length; i += 1) {
|
|
401
|
+
if (completeArgs[i] === "--run-id" && completeArgs[i + 1]) {
|
|
402
|
+
runId = completeArgs[i + 1];
|
|
403
|
+
i += 1;
|
|
404
|
+
}
|
|
405
|
+
else if (completeArgs[i] === "--step-id" && completeArgs[i + 1]) {
|
|
406
|
+
stepId = completeArgs[i + 1];
|
|
407
|
+
i += 1;
|
|
408
|
+
}
|
|
409
|
+
else if (completeArgs[i] === "--data" && completeArgs[i + 1]) {
|
|
410
|
+
dataRaw = completeArgs[i + 1];
|
|
411
|
+
i += 1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!runId || !stepId || dataRaw === undefined) {
|
|
415
|
+
console.error("Missing required flags. Usage: soulforge complete --run-id <id> --step-id <id> --data '<json>'");
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
let data;
|
|
419
|
+
try {
|
|
420
|
+
data = JSON.parse(dataRaw);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
console.error("Invalid JSON for --data.");
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
const run = getRun(runId);
|
|
427
|
+
if (!run) {
|
|
428
|
+
console.error(`Run not found: ${runId}`);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
const db = getDb();
|
|
432
|
+
const step = db.prepare("SELECT id, step_id, output_schema, status FROM steps WHERE run_id = ? AND (step_id = ? OR id = ?) LIMIT 1").get(run.id, stepId, stepId);
|
|
433
|
+
if (!step) {
|
|
434
|
+
console.error(`Step not found for run ${run.id}: ${stepId}`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
if (!step.output_schema) {
|
|
438
|
+
console.error(`Step "${step.step_id}" does not define output_schema. soulforge complete only supports structured-output steps.`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
if (step.status !== "running") {
|
|
442
|
+
console.error(`Step "${step.step_id}" is not running (status: ${step.status}).`);
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
const runningStep = db.prepare("SELECT id FROM steps WHERE run_id = ? AND status = 'running' AND step_id = ? LIMIT 1").get(run.id, step.step_id);
|
|
446
|
+
if (!runningStep || runningStep.id !== step.id) {
|
|
447
|
+
console.error(`Step "${step.step_id}" is not the running step for this run.`);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
let schema;
|
|
451
|
+
try {
|
|
452
|
+
schema = JSON.parse(step.output_schema);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
console.error(`Stored output schema for step "${step.step_id}" is invalid JSON.`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
const validation = validateOutput(data, schema);
|
|
459
|
+
if (!validation.valid) {
|
|
460
|
+
console.error("Output validation failed:");
|
|
461
|
+
for (const error of validation.errors) {
|
|
462
|
+
console.error(error);
|
|
463
|
+
}
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
updateStepStatus(step.id, "done", { output: JSON.stringify(data) });
|
|
467
|
+
const runContext = JSON.parse(run.context);
|
|
468
|
+
if (isPlainObject(data)) {
|
|
469
|
+
updateRunContext(run.id, { ...runContext, ...data });
|
|
470
|
+
}
|
|
471
|
+
console.log(`Completed step "${step.step_id}" for run ${run.id}.`);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
307
474
|
// ── Status command ──────────────────────────────────────────────
|
|
308
475
|
if (group === "status") {
|
|
309
476
|
const query = [action, ...rest].filter(Boolean).join(" ");
|
|
310
477
|
if (!query) {
|
|
478
|
+
console.log(formatDaemonStatusLine());
|
|
311
479
|
// Show summary of active runs
|
|
312
480
|
const runs = listRuns(10);
|
|
313
481
|
const active = runs.filter((r) => r.status === "running" || r.status === "pending");
|
|
@@ -332,7 +500,7 @@ async function main() {
|
|
|
332
500
|
return;
|
|
333
501
|
}
|
|
334
502
|
const steps = getStepsForRun(run.id);
|
|
335
|
-
const
|
|
503
|
+
const loopItems = getLoopItemsForRun(run.id);
|
|
336
504
|
console.log(`Run: ${run.id}`);
|
|
337
505
|
console.log(`Workflow: ${run.workflow_id}`);
|
|
338
506
|
console.log(`Task: ${run.task.slice(0, 120)}`);
|
|
@@ -346,13 +514,13 @@ async function main() {
|
|
|
346
514
|
const model = s.model ? ` [${s.model}]` : "";
|
|
347
515
|
console.log(` [${s.status.padEnd(17)}] ${s.step_id} (${s.executor})${model}${dur}`);
|
|
348
516
|
}
|
|
349
|
-
if (
|
|
350
|
-
const done =
|
|
351
|
-
const failed =
|
|
517
|
+
if (loopItems.length > 0) {
|
|
518
|
+
const done = loopItems.filter((s) => s.status === "done").length;
|
|
519
|
+
const failed = loopItems.filter((s) => s.status === "failed").length;
|
|
352
520
|
console.log();
|
|
353
|
-
console.log(`Stories: ${done}/${
|
|
354
|
-
for (const s of
|
|
355
|
-
console.log(` ${s.
|
|
521
|
+
console.log(`Stories: ${done}/${loopItems.length} done${failed ? `, ${failed} failed` : ""}`);
|
|
522
|
+
for (const s of loopItems) {
|
|
523
|
+
console.log(` ${s.item_id.padEnd(10)} [${s.status.padEnd(7)}] ${s.title}`);
|
|
356
524
|
}
|
|
357
525
|
}
|
|
358
526
|
return;
|
|
@@ -394,6 +562,44 @@ async function main() {
|
|
|
394
562
|
// Approve — mark step done and advance
|
|
395
563
|
updateStepStatus(step.id, "done", { output: `APPROVED: ${message}` });
|
|
396
564
|
insertEvent(run.id, "step_complete", `Checkpoint "${step.step_id}" approved: ${message}`, step.step_id);
|
|
565
|
+
if (step.type === "gate" && step.gate_config) {
|
|
566
|
+
const gateConfig = JSON.parse(step.gate_config);
|
|
567
|
+
const runContext = JSON.parse(run.context);
|
|
568
|
+
const loopCounts = parseGateLoopCounts(runContext.__gate_loop_counts);
|
|
569
|
+
const gateLoopCount = (loopCounts[step.step_id] ?? 0) + 1;
|
|
570
|
+
loopCounts[step.step_id] = gateLoopCount;
|
|
571
|
+
const contextWithLoopCount = {
|
|
572
|
+
...runContext,
|
|
573
|
+
__gate_loop_counts: JSON.stringify(loopCounts),
|
|
574
|
+
};
|
|
575
|
+
updateRunContext(run.id, contextWithLoopCount);
|
|
576
|
+
if (gateLoopCount > gateConfig.max_loops) {
|
|
577
|
+
const error = `Gate step "${step.step_id}" exceeded max_loops (${gateConfig.max_loops})`;
|
|
578
|
+
updateStepStatus(step.id, "failed", { error });
|
|
579
|
+
updateRunStatus(run.id, "failed");
|
|
580
|
+
insertEvent(run.id, "step_failed", `Step "${step.step_id}" failed: ${error}`, step.step_id, error);
|
|
581
|
+
insertEvent(run.id, "run_failed", `Run failed at step "${step.step_id}"`, step.step_id, error);
|
|
582
|
+
console.error(error);
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
const decisionValue = getContextValue(contextWithLoopCount, gateConfig.decision_var);
|
|
586
|
+
const routeStepId = resolveGateRoute(gateConfig, decisionValue);
|
|
587
|
+
const target = db.prepare("SELECT id FROM steps WHERE run_id = ? AND step_id = ? LIMIT 1").get(run.id, routeStepId);
|
|
588
|
+
if (!target) {
|
|
589
|
+
const error = `Gate step "${step.step_id}" resolved route "${routeStepId}" but target step was not found`;
|
|
590
|
+
updateStepStatus(step.id, "failed", { error });
|
|
591
|
+
updateRunStatus(run.id, "failed");
|
|
592
|
+
insertEvent(run.id, "step_failed", `Step "${step.step_id}" failed: ${error}`, step.step_id, error);
|
|
593
|
+
insertEvent(run.id, "run_failed", `Run failed at step "${step.step_id}"`, step.step_id, error);
|
|
594
|
+
console.error(error);
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
updateStepStatus(target.id, "pending");
|
|
598
|
+
const detail = `decision_var=${gateConfig.decision_var}, value=${decisionValue ?? "(missing)"}, target=${routeStepId}, loop=${gateLoopCount}/${gateConfig.max_loops}`;
|
|
599
|
+
insertEvent(run.id, "gate_routed", `Gate "${step.step_id}" routed to "${routeStepId}"`, step.step_id, detail);
|
|
600
|
+
console.log(`Checkpoint "${step.step_id}" approved. Routed to "${routeStepId}".`);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
397
603
|
const { runCompleted } = advancePipeline(run.id);
|
|
398
604
|
if (runCompleted) {
|
|
399
605
|
insertEvent(run.id, "run_complete", `Run completed: "${run.task}"`);
|
|
@@ -482,9 +688,9 @@ async function main() {
|
|
|
482
688
|
// Reset step to pending
|
|
483
689
|
updateStepStatus(failedStep.id, "pending", { error: null, current_story_id: null });
|
|
484
690
|
updateRunStatus(run.id, "running");
|
|
485
|
-
// If loop step, reset failed
|
|
691
|
+
// If loop step, reset failed loop_items
|
|
486
692
|
if (failedStep.type === "loop") {
|
|
487
|
-
db.prepare("UPDATE
|
|
693
|
+
db.prepare("UPDATE loop_items SET status = 'pending', updated_at = ? WHERE run_id = ? AND status = 'failed'").run(new Date().toISOString(), run.id);
|
|
488
694
|
}
|
|
489
695
|
insertEvent(run.id, "run_resumed", `Run resumed from step "${failedStep.step_id}"`);
|
|
490
696
|
console.log(`Resumed run ${run.id.slice(0, 8)} from step "${failedStep.step_id}".`);
|