@hasna/loops 0.1.0 → 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.
- package/README.md +88 -3
- package/dist/cli/index.js +1479 -75
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/index.js +1156 -52
- package/dist/daemon/install.d.ts +8 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1335 -57
- package/dist/lib/accounts.d.ts +4 -0
- package/dist/lib/doctor.d.ts +13 -0
- package/dist/lib/executor.d.ts +20 -1
- package/dist/lib/format.d.ts +5 -1
- package/dist/lib/scheduler.d.ts +12 -0
- package/dist/lib/store.d.ts +47 -2
- package/dist/lib/store.js +627 -6
- package/dist/lib/workflow-runner.d.ts +14 -0
- package/dist/lib/workflow-spec.d.ts +5 -0
- package/dist/sdk/index.js +1126 -57
- package/dist/types.d.ts +88 -2
- package/docs/USAGE.md +89 -4
- package/package.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -236,6 +236,103 @@ function parseDuration(input) {
|
|
|
236
236
|
return Math.round(value * multiplier);
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
// src/lib/workflow-spec.ts
|
|
240
|
+
function assertObject(value, label) {
|
|
241
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
242
|
+
throw new Error(`${label} must be an object`);
|
|
243
|
+
}
|
|
244
|
+
function assertString(value, label) {
|
|
245
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
246
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
247
|
+
}
|
|
248
|
+
function validateTarget(value, label) {
|
|
249
|
+
assertObject(value, label);
|
|
250
|
+
if (value.type === "command") {
|
|
251
|
+
assertString(value.command, `${label}.command`);
|
|
252
|
+
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
253
|
+
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
254
|
+
}
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
if (value.type === "agent") {
|
|
258
|
+
assertString(value.provider, `${label}.provider`);
|
|
259
|
+
assertString(value.prompt, `${label}.prompt`);
|
|
260
|
+
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
261
|
+
if (!providers.includes(value.provider))
|
|
262
|
+
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
throw new Error(`${label}.type must be command or agent`);
|
|
266
|
+
}
|
|
267
|
+
function normalizeCreateWorkflowInput(input) {
|
|
268
|
+
assertString(input.name, "workflow.name");
|
|
269
|
+
if (!Array.isArray(input.steps) || input.steps.length === 0)
|
|
270
|
+
throw new Error("workflow.steps must contain at least one step");
|
|
271
|
+
const seen = new Set;
|
|
272
|
+
const steps = input.steps.map((step, index) => {
|
|
273
|
+
assertObject(step, `workflow.steps[${index}]`);
|
|
274
|
+
assertString(step.id, `workflow.steps[${index}].id`);
|
|
275
|
+
if (seen.has(step.id))
|
|
276
|
+
throw new Error(`duplicate workflow step id: ${step.id}`);
|
|
277
|
+
seen.add(step.id);
|
|
278
|
+
return {
|
|
279
|
+
...step,
|
|
280
|
+
id: step.id,
|
|
281
|
+
target: validateTarget(step.target, `workflow.steps[${index}].target`),
|
|
282
|
+
dependsOn: step.dependsOn ?? [],
|
|
283
|
+
continueOnFailure: step.continueOnFailure ?? false
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
for (const step of steps) {
|
|
287
|
+
for (const dependency of step.dependsOn ?? []) {
|
|
288
|
+
if (!seen.has(dependency))
|
|
289
|
+
throw new Error(`step ${step.id} depends on missing step ${dependency}`);
|
|
290
|
+
if (dependency === step.id)
|
|
291
|
+
throw new Error(`step ${step.id} cannot depend on itself`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
workflowExecutionOrder({ steps });
|
|
295
|
+
return { ...input, name: input.name.trim(), version: input.version ?? 1, steps };
|
|
296
|
+
}
|
|
297
|
+
function workflowExecutionOrder(workflow) {
|
|
298
|
+
const byId = new Map(workflow.steps.map((step) => [step.id, step]));
|
|
299
|
+
const visiting = new Set;
|
|
300
|
+
const visited = new Set;
|
|
301
|
+
const order = [];
|
|
302
|
+
function visit(step) {
|
|
303
|
+
if (visited.has(step.id))
|
|
304
|
+
return;
|
|
305
|
+
if (visiting.has(step.id))
|
|
306
|
+
throw new Error(`workflow dependency cycle includes step ${step.id}`);
|
|
307
|
+
visiting.add(step.id);
|
|
308
|
+
for (const dependencyId of step.dependsOn ?? []) {
|
|
309
|
+
const dependency = byId.get(dependencyId);
|
|
310
|
+
if (!dependency)
|
|
311
|
+
throw new Error(`step ${step.id} depends on missing step ${dependencyId}`);
|
|
312
|
+
visit(dependency);
|
|
313
|
+
}
|
|
314
|
+
visiting.delete(step.id);
|
|
315
|
+
visited.add(step.id);
|
|
316
|
+
order.push(step);
|
|
317
|
+
}
|
|
318
|
+
for (const step of workflow.steps)
|
|
319
|
+
visit(step);
|
|
320
|
+
return order;
|
|
321
|
+
}
|
|
322
|
+
function workflowBodyFromJson(value, fallbackName) {
|
|
323
|
+
assertObject(value, "workflow file");
|
|
324
|
+
const rawName = fallbackName ?? value.name;
|
|
325
|
+
assertString(rawName, "workflow.name");
|
|
326
|
+
if (!Array.isArray(value.steps))
|
|
327
|
+
throw new Error("workflow.steps must be an array");
|
|
328
|
+
return normalizeCreateWorkflowInput({
|
|
329
|
+
name: rawName,
|
|
330
|
+
description: typeof value.description === "string" ? value.description : undefined,
|
|
331
|
+
version: typeof value.version === "number" ? value.version : undefined,
|
|
332
|
+
steps: value.steps
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
239
336
|
// src/lib/store.ts
|
|
240
337
|
function rowToLoop(row) {
|
|
241
338
|
return {
|
|
@@ -280,6 +377,76 @@ function rowToRun(row) {
|
|
|
280
377
|
updatedAt: row.updated_at
|
|
281
378
|
};
|
|
282
379
|
}
|
|
380
|
+
function rowToWorkflow(row) {
|
|
381
|
+
return {
|
|
382
|
+
id: row.id,
|
|
383
|
+
name: row.name,
|
|
384
|
+
description: row.description ?? undefined,
|
|
385
|
+
version: row.version,
|
|
386
|
+
status: row.status,
|
|
387
|
+
steps: JSON.parse(row.steps_json),
|
|
388
|
+
createdAt: row.created_at,
|
|
389
|
+
updatedAt: row.updated_at
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function rowToWorkflowRun(row) {
|
|
393
|
+
return {
|
|
394
|
+
id: row.id,
|
|
395
|
+
workflowId: row.workflow_id,
|
|
396
|
+
workflowName: row.workflow_name,
|
|
397
|
+
loopId: row.loop_id ?? undefined,
|
|
398
|
+
loopRunId: row.loop_run_id ?? undefined,
|
|
399
|
+
scheduledFor: row.scheduled_for ?? undefined,
|
|
400
|
+
idempotencyKey: row.idempotency_key ?? undefined,
|
|
401
|
+
status: row.status,
|
|
402
|
+
startedAt: row.started_at ?? undefined,
|
|
403
|
+
finishedAt: row.finished_at ?? undefined,
|
|
404
|
+
durationMs: row.duration_ms ?? undefined,
|
|
405
|
+
error: row.error ?? undefined,
|
|
406
|
+
createdAt: row.created_at,
|
|
407
|
+
updatedAt: row.updated_at
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function rowToWorkflowStepRun(row) {
|
|
411
|
+
return {
|
|
412
|
+
id: row.id,
|
|
413
|
+
workflowRunId: row.workflow_run_id,
|
|
414
|
+
stepId: row.step_id,
|
|
415
|
+
sequence: row.sequence,
|
|
416
|
+
status: row.status,
|
|
417
|
+
startedAt: row.started_at ?? undefined,
|
|
418
|
+
finishedAt: row.finished_at ?? undefined,
|
|
419
|
+
exitCode: row.exit_code ?? undefined,
|
|
420
|
+
pid: row.pid ?? undefined,
|
|
421
|
+
durationMs: row.duration_ms ?? undefined,
|
|
422
|
+
stdout: row.stdout ?? undefined,
|
|
423
|
+
stderr: row.stderr ?? undefined,
|
|
424
|
+
error: row.error ?? undefined,
|
|
425
|
+
accountProfile: row.account_profile ?? undefined,
|
|
426
|
+
accountTool: row.account_tool ?? undefined,
|
|
427
|
+
createdAt: row.created_at,
|
|
428
|
+
updatedAt: row.updated_at
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function rowToWorkflowEvent(row) {
|
|
432
|
+
return {
|
|
433
|
+
id: row.id,
|
|
434
|
+
workflowRunId: row.workflow_run_id,
|
|
435
|
+
sequence: row.sequence,
|
|
436
|
+
eventType: row.event_type,
|
|
437
|
+
stepId: row.step_id ?? undefined,
|
|
438
|
+
payload: row.payload_json ? JSON.parse(row.payload_json) : undefined,
|
|
439
|
+
createdAt: row.created_at
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function isProcessAlive(pid) {
|
|
443
|
+
try {
|
|
444
|
+
process.kill(pid, 0);
|
|
445
|
+
return true;
|
|
446
|
+
} catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
283
450
|
function rowToLease(row) {
|
|
284
451
|
return {
|
|
285
452
|
id: row.id,
|
|
@@ -305,6 +472,11 @@ class Store {
|
|
|
305
472
|
}
|
|
306
473
|
migrate() {
|
|
307
474
|
this.db.exec(`
|
|
475
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
476
|
+
id TEXT PRIMARY KEY,
|
|
477
|
+
applied_at TEXT NOT NULL
|
|
478
|
+
);
|
|
479
|
+
|
|
308
480
|
CREATE TABLE IF NOT EXISTS loops (
|
|
309
481
|
id TEXT PRIMARY KEY,
|
|
310
482
|
name TEXT NOT NULL,
|
|
@@ -361,7 +533,82 @@ class Store {
|
|
|
361
533
|
created_at TEXT NOT NULL,
|
|
362
534
|
updated_at TEXT NOT NULL
|
|
363
535
|
);
|
|
536
|
+
|
|
537
|
+
CREATE TABLE IF NOT EXISTS workflow_specs (
|
|
538
|
+
id TEXT PRIMARY KEY,
|
|
539
|
+
name TEXT NOT NULL,
|
|
540
|
+
description TEXT,
|
|
541
|
+
version INTEGER NOT NULL,
|
|
542
|
+
status TEXT NOT NULL,
|
|
543
|
+
steps_json TEXT NOT NULL,
|
|
544
|
+
created_at TEXT NOT NULL,
|
|
545
|
+
updated_at TEXT NOT NULL
|
|
546
|
+
);
|
|
547
|
+
CREATE INDEX IF NOT EXISTS idx_workflows_status_name ON workflow_specs(status, name);
|
|
548
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_name_active ON workflow_specs(name) WHERE status = 'active';
|
|
549
|
+
|
|
550
|
+
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
551
|
+
id TEXT PRIMARY KEY,
|
|
552
|
+
workflow_id TEXT NOT NULL REFERENCES workflow_specs(id) ON DELETE CASCADE,
|
|
553
|
+
workflow_name TEXT NOT NULL,
|
|
554
|
+
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
555
|
+
loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
|
|
556
|
+
scheduled_for TEXT,
|
|
557
|
+
idempotency_key TEXT,
|
|
558
|
+
status TEXT NOT NULL,
|
|
559
|
+
started_at TEXT,
|
|
560
|
+
finished_at TEXT,
|
|
561
|
+
duration_ms INTEGER,
|
|
562
|
+
error TEXT,
|
|
563
|
+
created_at TEXT NOT NULL,
|
|
564
|
+
updated_at TEXT NOT NULL
|
|
565
|
+
);
|
|
566
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_idempotency
|
|
567
|
+
ON workflow_runs(workflow_id, idempotency_key)
|
|
568
|
+
WHERE idempotency_key IS NOT NULL;
|
|
569
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
|
|
570
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
|
|
571
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
572
|
+
|
|
573
|
+
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
574
|
+
id TEXT PRIMARY KEY,
|
|
575
|
+
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
576
|
+
step_id TEXT NOT NULL,
|
|
577
|
+
sequence INTEGER NOT NULL,
|
|
578
|
+
status TEXT NOT NULL,
|
|
579
|
+
started_at TEXT,
|
|
580
|
+
finished_at TEXT,
|
|
581
|
+
exit_code INTEGER,
|
|
582
|
+
pid INTEGER,
|
|
583
|
+
duration_ms INTEGER,
|
|
584
|
+
stdout TEXT,
|
|
585
|
+
stderr TEXT,
|
|
586
|
+
error TEXT,
|
|
587
|
+
account_profile TEXT,
|
|
588
|
+
account_tool TEXT,
|
|
589
|
+
created_at TEXT NOT NULL,
|
|
590
|
+
updated_at TEXT NOT NULL,
|
|
591
|
+
UNIQUE(workflow_run_id, step_id)
|
|
592
|
+
);
|
|
593
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_run_sequence ON workflow_step_runs(workflow_run_id, sequence);
|
|
594
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_status ON workflow_step_runs(status);
|
|
595
|
+
|
|
596
|
+
CREATE TABLE IF NOT EXISTS workflow_events (
|
|
597
|
+
id TEXT PRIMARY KEY,
|
|
598
|
+
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
599
|
+
sequence INTEGER NOT NULL,
|
|
600
|
+
event_type TEXT NOT NULL,
|
|
601
|
+
step_id TEXT,
|
|
602
|
+
payload_json TEXT,
|
|
603
|
+
created_at TEXT NOT NULL,
|
|
604
|
+
UNIQUE(workflow_run_id, sequence)
|
|
605
|
+
);
|
|
606
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
364
607
|
`);
|
|
608
|
+
try {
|
|
609
|
+
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
610
|
+
} catch {}
|
|
611
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
365
612
|
}
|
|
366
613
|
createLoop(input, from = new Date) {
|
|
367
614
|
const now = nowIso();
|
|
@@ -453,10 +700,343 @@ class Store {
|
|
|
453
700
|
const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
|
|
454
701
|
return res.changes > 0;
|
|
455
702
|
}
|
|
703
|
+
createWorkflow(input) {
|
|
704
|
+
const normalized = normalizeCreateWorkflowInput(input);
|
|
705
|
+
const now = nowIso();
|
|
706
|
+
const workflow = {
|
|
707
|
+
id: genId(),
|
|
708
|
+
name: normalized.name,
|
|
709
|
+
description: normalized.description,
|
|
710
|
+
version: normalized.version ?? 1,
|
|
711
|
+
status: "active",
|
|
712
|
+
steps: normalized.steps,
|
|
713
|
+
createdAt: now,
|
|
714
|
+
updatedAt: now
|
|
715
|
+
};
|
|
716
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, steps_json, created_at, updated_at)
|
|
717
|
+
VALUES ($id, $name, $description, $version, $status, $steps, $created, $updated)`).run({
|
|
718
|
+
$id: workflow.id,
|
|
719
|
+
$name: workflow.name,
|
|
720
|
+
$description: workflow.description ?? null,
|
|
721
|
+
$version: workflow.version,
|
|
722
|
+
$status: workflow.status,
|
|
723
|
+
$steps: JSON.stringify(workflow.steps),
|
|
724
|
+
$created: workflow.createdAt,
|
|
725
|
+
$updated: workflow.updatedAt
|
|
726
|
+
});
|
|
727
|
+
return workflow;
|
|
728
|
+
}
|
|
729
|
+
getWorkflow(id) {
|
|
730
|
+
const row = this.db.query("SELECT * FROM workflow_specs WHERE id = ?").get(id);
|
|
731
|
+
return row ? rowToWorkflow(row) : undefined;
|
|
732
|
+
}
|
|
733
|
+
findWorkflowByName(name) {
|
|
734
|
+
const row = this.db.query("SELECT * FROM workflow_specs WHERE name = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 1").get(name);
|
|
735
|
+
return row ? rowToWorkflow(row) : undefined;
|
|
736
|
+
}
|
|
737
|
+
requireWorkflow(idOrName) {
|
|
738
|
+
return this.getWorkflow(idOrName) ?? this.findWorkflowByName(idOrName) ?? (() => {
|
|
739
|
+
throw new Error(`workflow not found: ${idOrName}`);
|
|
740
|
+
})();
|
|
741
|
+
}
|
|
742
|
+
listWorkflows(opts = {}) {
|
|
743
|
+
const limit = opts.limit ?? 200;
|
|
744
|
+
const rows = opts.status ? this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ?").all(limit);
|
|
745
|
+
return rows.map(rowToWorkflow);
|
|
746
|
+
}
|
|
747
|
+
archiveWorkflow(idOrName) {
|
|
748
|
+
const workflow = this.requireWorkflow(idOrName);
|
|
749
|
+
const updated = nowIso();
|
|
750
|
+
this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=?").run(updated, workflow.id);
|
|
751
|
+
const archived = this.getWorkflow(workflow.id);
|
|
752
|
+
if (!archived)
|
|
753
|
+
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
754
|
+
return archived;
|
|
755
|
+
}
|
|
756
|
+
createWorkflowRun(input) {
|
|
757
|
+
const now = nowIso();
|
|
758
|
+
if (input.idempotencyKey) {
|
|
759
|
+
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
760
|
+
if (existing)
|
|
761
|
+
return rowToWorkflowRun(existing);
|
|
762
|
+
}
|
|
763
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
764
|
+
try {
|
|
765
|
+
if (input.idempotencyKey) {
|
|
766
|
+
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
767
|
+
if (existing) {
|
|
768
|
+
this.db.exec("COMMIT");
|
|
769
|
+
return rowToWorkflowRun(existing);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const runId = genId();
|
|
773
|
+
this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
|
|
774
|
+
status, started_at, finished_at, duration_ms, error, created_at, updated_at)
|
|
775
|
+
VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
|
|
776
|
+
'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
|
|
777
|
+
$id: runId,
|
|
778
|
+
$workflowId: input.workflow.id,
|
|
779
|
+
$workflowName: input.workflow.name,
|
|
780
|
+
$loopId: input.loop?.id ?? null,
|
|
781
|
+
$loopRunId: input.loopRun?.id ?? null,
|
|
782
|
+
$scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
|
|
783
|
+
$idempotencyKey: input.idempotencyKey ?? null,
|
|
784
|
+
$started: now,
|
|
785
|
+
$created: now,
|
|
786
|
+
$updated: now
|
|
787
|
+
});
|
|
788
|
+
input.workflow.steps.forEach((step, sequence) => {
|
|
789
|
+
const account = step.account ?? step.target.account;
|
|
790
|
+
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
791
|
+
exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
792
|
+
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
793
|
+
$accountProfile, $accountTool, $created, $updated)`).run({
|
|
794
|
+
$id: genId(),
|
|
795
|
+
$workflowRunId: runId,
|
|
796
|
+
$stepId: step.id,
|
|
797
|
+
$sequence: sequence,
|
|
798
|
+
$accountProfile: account?.profile ?? null,
|
|
799
|
+
$accountTool: account?.tool ?? null,
|
|
800
|
+
$created: now,
|
|
801
|
+
$updated: now
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
|
|
805
|
+
VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
|
|
806
|
+
$id: genId(),
|
|
807
|
+
$workflowRunId: runId,
|
|
808
|
+
$payload: JSON.stringify({
|
|
809
|
+
workflowId: input.workflow.id,
|
|
810
|
+
workflowName: input.workflow.name,
|
|
811
|
+
stepCount: input.workflow.steps.length,
|
|
812
|
+
loopId: input.loop?.id,
|
|
813
|
+
loopRunId: input.loopRun?.id
|
|
814
|
+
}),
|
|
815
|
+
$created: now
|
|
816
|
+
});
|
|
817
|
+
this.db.exec("COMMIT");
|
|
818
|
+
const run = this.getWorkflowRun(runId);
|
|
819
|
+
if (!run)
|
|
820
|
+
throw new Error(`workflow run not found after create: ${runId}`);
|
|
821
|
+
return run;
|
|
822
|
+
} catch (error) {
|
|
823
|
+
try {
|
|
824
|
+
this.db.exec("ROLLBACK");
|
|
825
|
+
} catch {}
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
getWorkflowRun(id) {
|
|
830
|
+
const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
|
|
831
|
+
return row ? rowToWorkflowRun(row) : undefined;
|
|
832
|
+
}
|
|
833
|
+
requireWorkflowRun(id) {
|
|
834
|
+
const run = this.getWorkflowRun(id);
|
|
835
|
+
if (!run)
|
|
836
|
+
throw new Error(`workflow run not found: ${id}`);
|
|
837
|
+
return run;
|
|
838
|
+
}
|
|
839
|
+
listWorkflowRuns(opts = {}) {
|
|
840
|
+
const limit = opts.limit ?? 100;
|
|
841
|
+
let rows;
|
|
842
|
+
if (opts.workflowId) {
|
|
843
|
+
rows = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.workflowId, limit);
|
|
844
|
+
} else if (opts.loopRunId) {
|
|
845
|
+
rows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopRunId, limit);
|
|
846
|
+
} else {
|
|
847
|
+
rows = this.db.query("SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
848
|
+
}
|
|
849
|
+
return rows.map(rowToWorkflowRun);
|
|
850
|
+
}
|
|
851
|
+
listWorkflowStepRuns(workflowRunId) {
|
|
852
|
+
const rows = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? ORDER BY sequence ASC").all(workflowRunId);
|
|
853
|
+
return rows.map(rowToWorkflowStepRun);
|
|
854
|
+
}
|
|
855
|
+
getWorkflowStepRun(workflowRunId, stepId) {
|
|
856
|
+
const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
|
|
857
|
+
return row ? rowToWorkflowStepRun(row) : undefined;
|
|
858
|
+
}
|
|
859
|
+
isWorkflowRunTerminal(workflowRunId) {
|
|
860
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
861
|
+
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
862
|
+
}
|
|
863
|
+
startWorkflowStepRun(workflowRunId, stepId) {
|
|
864
|
+
const now = nowIso();
|
|
865
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
866
|
+
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
867
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
868
|
+
WHERE workflow_run_id=$workflowRunId
|
|
869
|
+
AND step_id=$stepId
|
|
870
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
871
|
+
AND EXISTS (
|
|
872
|
+
SELECT 1 FROM workflow_runs
|
|
873
|
+
WHERE id=$workflowRunId AND status='running'
|
|
874
|
+
)`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
|
|
875
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
876
|
+
if (!run)
|
|
877
|
+
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
878
|
+
if (res.changes !== 1) {
|
|
879
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
880
|
+
}
|
|
881
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
882
|
+
return run;
|
|
883
|
+
}
|
|
884
|
+
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
885
|
+
const now = nowIso();
|
|
886
|
+
this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
|
|
887
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
|
|
888
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
889
|
+
if (!run)
|
|
890
|
+
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
891
|
+
return run;
|
|
892
|
+
}
|
|
893
|
+
recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
|
|
894
|
+
const now = nowIso();
|
|
895
|
+
const before = this.listWorkflowStepRuns(workflowRunId).filter((step) => step.status === "running");
|
|
896
|
+
const live = before.filter((step) => step.pid !== undefined && isProcessAlive(step.pid));
|
|
897
|
+
if (live.length > 0) {
|
|
898
|
+
throw new Error(`cannot recover workflow run while step processes are still alive: ${live.map((step) => `${step.stepId} pid=${step.pid}`).join(", ")}`);
|
|
899
|
+
}
|
|
900
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
901
|
+
SET status='pending', started_at=NULL, finished_at=NULL, exit_code=NULL, pid=NULL, duration_ms=NULL,
|
|
902
|
+
stdout=NULL, stderr=NULL, error=$reason, updated_at=$updated
|
|
903
|
+
WHERE workflow_run_id=$workflowRunId AND status='running'`).run({ $workflowRunId: workflowRunId, $reason: reason, $updated: now });
|
|
904
|
+
if (before.length > 0) {
|
|
905
|
+
this.appendWorkflowEvent(workflowRunId, "recovered", undefined, {
|
|
906
|
+
reason,
|
|
907
|
+
recoveredSteps: before.map((step) => step.stepId)
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
run: this.requireWorkflowRun(workflowRunId),
|
|
912
|
+
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
916
|
+
const finishedAt = patch.finishedAt ?? nowIso();
|
|
917
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
918
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
919
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
|
|
920
|
+
$workflowRunId: workflowRunId,
|
|
921
|
+
$stepId: stepId,
|
|
922
|
+
$status: patch.status,
|
|
923
|
+
$finished: finishedAt,
|
|
924
|
+
$exitCode: patch.exitCode ?? null,
|
|
925
|
+
$durationMs: patch.durationMs ?? null,
|
|
926
|
+
$stdout: patch.stdout ?? null,
|
|
927
|
+
$stderr: patch.stderr ?? null,
|
|
928
|
+
$error: patch.error ?? null,
|
|
929
|
+
$updated: finishedAt
|
|
930
|
+
});
|
|
931
|
+
if (res.changes === 1) {
|
|
932
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
933
|
+
exitCode: patch.exitCode,
|
|
934
|
+
error: patch.error
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
938
|
+
if (!run)
|
|
939
|
+
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
940
|
+
return run;
|
|
941
|
+
}
|
|
942
|
+
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
943
|
+
const now = nowIso();
|
|
944
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
945
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
|
|
946
|
+
if (res.changes === 1)
|
|
947
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
948
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
949
|
+
if (!run)
|
|
950
|
+
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
951
|
+
return run;
|
|
952
|
+
}
|
|
953
|
+
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
954
|
+
const finishedAt = patch.finishedAt ?? nowIso();
|
|
955
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
956
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
|
|
957
|
+
$id: workflowRunId,
|
|
958
|
+
$status: status,
|
|
959
|
+
$finished: finishedAt,
|
|
960
|
+
$durationMs: patch.durationMs ?? null,
|
|
961
|
+
$error: patch.error ?? null,
|
|
962
|
+
$updated: finishedAt
|
|
963
|
+
});
|
|
964
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
965
|
+
if (!run)
|
|
966
|
+
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
967
|
+
if (res.changes === 1)
|
|
968
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
969
|
+
return run;
|
|
970
|
+
}
|
|
971
|
+
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
972
|
+
const now = nowIso();
|
|
973
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
974
|
+
try {
|
|
975
|
+
const run = this.requireWorkflowRun(workflowRunId);
|
|
976
|
+
if (!["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)) {
|
|
977
|
+
this.db.query(`UPDATE workflow_runs
|
|
978
|
+
SET status='cancelled', finished_at=$finished, error=$reason, updated_at=$updated
|
|
979
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
980
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
981
|
+
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
982
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
983
|
+
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
984
|
+
}
|
|
985
|
+
this.db.exec("COMMIT");
|
|
986
|
+
return this.requireWorkflowRun(workflowRunId);
|
|
987
|
+
} catch (error) {
|
|
988
|
+
try {
|
|
989
|
+
this.db.exec("ROLLBACK");
|
|
990
|
+
} catch {}
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
|
|
995
|
+
const now = nowIso();
|
|
996
|
+
const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
|
|
997
|
+
const sequence = (current?.sequence ?? 0) + 1;
|
|
998
|
+
const id = genId();
|
|
999
|
+
this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
|
|
1000
|
+
VALUES ($id, $workflowRunId, $sequence, $eventType, $stepId, $payload, $created)`).run({
|
|
1001
|
+
$id: id,
|
|
1002
|
+
$workflowRunId: workflowRunId,
|
|
1003
|
+
$sequence: sequence,
|
|
1004
|
+
$eventType: eventType,
|
|
1005
|
+
$stepId: stepId ?? null,
|
|
1006
|
+
$payload: payload ? JSON.stringify(payload) : null,
|
|
1007
|
+
$created: now
|
|
1008
|
+
});
|
|
1009
|
+
const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
|
|
1010
|
+
if (!event)
|
|
1011
|
+
throw new Error(`workflow event not found after append: ${id}`);
|
|
1012
|
+
return rowToWorkflowEvent(event);
|
|
1013
|
+
}
|
|
1014
|
+
listWorkflowEvents(workflowRunId, limit = 200) {
|
|
1015
|
+
const rows = this.db.query("SELECT * FROM workflow_events WHERE workflow_run_id = ? ORDER BY sequence ASC LIMIT ?").all(workflowRunId, limit);
|
|
1016
|
+
return rows.map(rowToWorkflowEvent);
|
|
1017
|
+
}
|
|
456
1018
|
hasRunningRun(loopId) {
|
|
457
1019
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
458
1020
|
return (row?.count ?? 0) > 0;
|
|
459
1021
|
}
|
|
1022
|
+
markRunPid(id, pid, claimedBy) {
|
|
1023
|
+
const now = nowIso();
|
|
1024
|
+
const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1025
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $pid: pid, $updated: now, $claimedBy: claimedBy }) : this.db.query("UPDATE loop_runs SET pid=$pid, updated_at=$updated WHERE id=$id AND status='running'").run({ $id: id, $pid: pid, $updated: now });
|
|
1026
|
+
if (res.changes !== 1)
|
|
1027
|
+
return;
|
|
1028
|
+
return this.getRun(id);
|
|
1029
|
+
}
|
|
1030
|
+
hasLiveWorkflowStepProcesses(loopRunId) {
|
|
1031
|
+
const liveWorkflowSteps = this.db.query(`SELECT wr.id AS workflow_run_id, wsr.step_id AS step_id, wsr.pid AS pid
|
|
1032
|
+
FROM workflow_runs wr
|
|
1033
|
+
JOIN workflow_step_runs wsr ON wsr.workflow_run_id = wr.id
|
|
1034
|
+
WHERE wr.loop_run_id = ?
|
|
1035
|
+
AND wr.status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1036
|
+
AND wsr.status = 'running'
|
|
1037
|
+
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1038
|
+
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1039
|
+
}
|
|
460
1040
|
createSkippedRun(loop, scheduledFor, reason) {
|
|
461
1041
|
const now = nowIso();
|
|
462
1042
|
const run = {
|
|
@@ -504,6 +1084,14 @@ class Store {
|
|
|
504
1084
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
505
1085
|
if (existing) {
|
|
506
1086
|
if (existing.status === "running") {
|
|
1087
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && existing.pid && isProcessAlive(existing.pid)) {
|
|
1088
|
+
this.db.exec("COMMIT");
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && this.hasLiveWorkflowStepProcesses(existing.id)) {
|
|
1092
|
+
this.db.exec("COMMIT");
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
507
1095
|
const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
|
|
508
1096
|
claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
|
|
509
1097
|
duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
@@ -573,10 +1161,9 @@ class Store {
|
|
|
573
1161
|
throw error;
|
|
574
1162
|
}
|
|
575
1163
|
}
|
|
576
|
-
finalizeRun(id, patch) {
|
|
1164
|
+
finalizeRun(id, patch, opts = {}) {
|
|
577
1165
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
578
|
-
|
|
579
|
-
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run({
|
|
1166
|
+
const params = {
|
|
580
1167
|
$id: id,
|
|
581
1168
|
$status: patch.status,
|
|
582
1169
|
$finished: finishedAt,
|
|
@@ -586,13 +1173,29 @@ class Store {
|
|
|
586
1173
|
$stdout: patch.stdout ?? null,
|
|
587
1174
|
$stderr: patch.stderr ?? null,
|
|
588
1175
|
$error: patch.error ?? null,
|
|
589
|
-
$updated: finishedAt
|
|
590
|
-
|
|
1176
|
+
$updated: finishedAt,
|
|
1177
|
+
$claimedBy: opts.claimedBy ?? null,
|
|
1178
|
+
$now: (opts.now ?? new Date).toISOString()
|
|
1179
|
+
};
|
|
1180
|
+
const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1181
|
+
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
1182
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1183
|
+
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
591
1184
|
const run = this.getRun(id);
|
|
592
1185
|
if (!run)
|
|
593
1186
|
throw new Error(`run not found after finalize: ${id}`);
|
|
1187
|
+
if (opts.claimedBy && res.changes !== 1)
|
|
1188
|
+
return run;
|
|
594
1189
|
return run;
|
|
595
1190
|
}
|
|
1191
|
+
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
|
|
1192
|
+
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1193
|
+
const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
|
|
1194
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $claimedBy: claimedBy, $expires: expiresAt, $updated: now.toISOString() });
|
|
1195
|
+
if (res.changes !== 1)
|
|
1196
|
+
return;
|
|
1197
|
+
return this.getRun(id);
|
|
1198
|
+
}
|
|
596
1199
|
listRuns(opts = {}) {
|
|
597
1200
|
const limit = opts.limit ?? 100;
|
|
598
1201
|
let rows;
|
|
@@ -611,8 +1214,26 @@ class Store {
|
|
|
611
1214
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
612
1215
|
const recovered = [];
|
|
613
1216
|
for (const row of rows) {
|
|
1217
|
+
if (row.pid && isProcessAlive(row.pid))
|
|
1218
|
+
continue;
|
|
1219
|
+
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1220
|
+
continue;
|
|
1221
|
+
const finished = now.toISOString();
|
|
614
1222
|
this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
615
|
-
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished:
|
|
1223
|
+
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
|
|
1224
|
+
const workflowRows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')").all(row.id);
|
|
1225
|
+
for (const workflowRow of workflowRows) {
|
|
1226
|
+
this.db.query(`UPDATE workflow_runs
|
|
1227
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1228
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
|
|
1229
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1230
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1231
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
|
|
1232
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1233
|
+
error: "parent loop run lease expired before completion",
|
|
1234
|
+
loopRunId: row.id
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
616
1237
|
const run = this.getRun(row.id);
|
|
617
1238
|
if (run)
|
|
618
1239
|
recovered.push(run);
|
|
@@ -684,7 +1305,7 @@ class Store {
|
|
|
684
1305
|
}
|
|
685
1306
|
|
|
686
1307
|
// src/cli/index.ts
|
|
687
|
-
import { existsSync as
|
|
1308
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
688
1309
|
import { Command } from "commander";
|
|
689
1310
|
|
|
690
1311
|
// src/lib/format.ts
|
|
@@ -696,7 +1317,7 @@ function redact(value, visible = 80) {
|
|
|
696
1317
|
return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
|
|
697
1318
|
}
|
|
698
1319
|
function publicLoop(loop) {
|
|
699
|
-
const target = loop.target.type === "command" ? { ...loop.target, env: loop.target.env ? "[redacted]" : undefined } : { ...loop.target, prompt: redact(loop.target.prompt) };
|
|
1320
|
+
const target = loop.target.type === "command" ? { ...loop.target, env: loop.target.env ? "[redacted]" : undefined } : loop.target.type === "agent" ? { ...loop.target, prompt: redact(loop.target.prompt) } : loop.target;
|
|
700
1321
|
return {
|
|
701
1322
|
...loop,
|
|
702
1323
|
target
|
|
@@ -709,12 +1330,150 @@ function publicRun(run, showOutput = false) {
|
|
|
709
1330
|
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
|
|
710
1331
|
};
|
|
711
1332
|
}
|
|
1333
|
+
function publicWorkflow(workflow) {
|
|
1334
|
+
return {
|
|
1335
|
+
...workflow,
|
|
1336
|
+
steps: workflow.steps.map((step) => ({
|
|
1337
|
+
...step,
|
|
1338
|
+
target: step.target.type === "agent" ? { ...step.target, prompt: redact(step.target.prompt) } : step.target.type === "command" && step.target.env ? { ...step.target, env: "[redacted]" } : step.target
|
|
1339
|
+
}))
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
function publicWorkflowRun(run) {
|
|
1343
|
+
return { ...run };
|
|
1344
|
+
}
|
|
1345
|
+
function publicWorkflowStepRun(run, showOutput = false) {
|
|
1346
|
+
return {
|
|
1347
|
+
...run,
|
|
1348
|
+
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
1349
|
+
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function publicWorkflowEvent(event) {
|
|
1353
|
+
return { ...event };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// src/lib/executor.ts
|
|
1357
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1358
|
+
import { once } from "events";
|
|
1359
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1360
|
+
|
|
1361
|
+
// src/lib/accounts.ts
|
|
1362
|
+
import { spawnSync } from "child_process";
|
|
1363
|
+
import { existsSync } from "fs";
|
|
1364
|
+
var EXPORT_RE = /^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
|
|
1365
|
+
function accountToolForProvider(provider) {
|
|
1366
|
+
switch (provider) {
|
|
1367
|
+
case "claude":
|
|
1368
|
+
return "claude";
|
|
1369
|
+
case "cursor":
|
|
1370
|
+
return "cursor";
|
|
1371
|
+
case "codewith":
|
|
1372
|
+
return "codewith";
|
|
1373
|
+
case "aicopilot":
|
|
1374
|
+
return "aicopilot";
|
|
1375
|
+
case "opencode":
|
|
1376
|
+
return "opencode";
|
|
1377
|
+
case "codex":
|
|
1378
|
+
return "codex";
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
function parseExportValue(raw) {
|
|
1382
|
+
try {
|
|
1383
|
+
return JSON.parse(raw);
|
|
1384
|
+
} catch {
|
|
1385
|
+
return raw.replace(/^['"]|['"]$/g, "");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
function parseAccountExportLines(output) {
|
|
1389
|
+
const env = {};
|
|
1390
|
+
for (const line of output.split(/\r?\n/)) {
|
|
1391
|
+
const match = EXPORT_RE.exec(line.trim());
|
|
1392
|
+
if (!match)
|
|
1393
|
+
continue;
|
|
1394
|
+
env[match[1]] = parseExportValue(match[2]);
|
|
1395
|
+
}
|
|
1396
|
+
return env;
|
|
1397
|
+
}
|
|
1398
|
+
function primaryAccountDir(output) {
|
|
1399
|
+
for (const line of output.split(/\r?\n/)) {
|
|
1400
|
+
const match = EXPORT_RE.exec(line.trim());
|
|
1401
|
+
if (!match)
|
|
1402
|
+
continue;
|
|
1403
|
+
return parseExportValue(match[2]);
|
|
1404
|
+
}
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
function accountDirEnvVar(tool) {
|
|
1408
|
+
switch (tool) {
|
|
1409
|
+
case "claude":
|
|
1410
|
+
return "CLAUDE_CONFIG_DIR";
|
|
1411
|
+
case "codex":
|
|
1412
|
+
case "codex-app":
|
|
1413
|
+
return "CODEX_HOME";
|
|
1414
|
+
case "cursor":
|
|
1415
|
+
return "CURSOR_CONFIG_DIR";
|
|
1416
|
+
case "opencode":
|
|
1417
|
+
return "OPENCODE_CONFIG_DIR";
|
|
1418
|
+
case "codewith":
|
|
1419
|
+
return "CODEWITH_HOME";
|
|
1420
|
+
case "aicopilot":
|
|
1421
|
+
return "AICOPILOT_CONFIG_DIR";
|
|
1422
|
+
default:
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
function resolveAccountEnv(account, toolHint, env) {
|
|
1427
|
+
if (!account)
|
|
1428
|
+
return {};
|
|
1429
|
+
const tool = account.tool ?? toolHint;
|
|
1430
|
+
if (!tool)
|
|
1431
|
+
throw new Error("account.tool is required when no provider tool can be inferred");
|
|
1432
|
+
const result = spawnSync("accounts", ["env", account.profile, "--tool", tool], {
|
|
1433
|
+
encoding: "utf8",
|
|
1434
|
+
env,
|
|
1435
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1436
|
+
});
|
|
1437
|
+
if (result.error) {
|
|
1438
|
+
throw new Error(`failed to run accounts env for ${account.profile}/${tool}: ${result.error.message}`);
|
|
1439
|
+
}
|
|
1440
|
+
if ((result.status ?? 0) !== 0) {
|
|
1441
|
+
const stderr = result.stderr.trim();
|
|
1442
|
+
throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
|
|
1443
|
+
}
|
|
1444
|
+
const accountEnv = parseAccountExportLines(result.stdout);
|
|
1445
|
+
const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
|
|
1446
|
+
if (!profileDir)
|
|
1447
|
+
throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
|
|
1448
|
+
if (!existsSync(profileDir))
|
|
1449
|
+
throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
|
|
1450
|
+
return {
|
|
1451
|
+
...accountEnv,
|
|
1452
|
+
LOOPS_ACCOUNT_PROFILE: account.profile,
|
|
1453
|
+
LOOPS_ACCOUNT_TOOL: tool
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
712
1456
|
|
|
713
1457
|
// src/lib/executor.ts
|
|
714
|
-
import { spawn } from "child_process";
|
|
715
|
-
import { once } from "events";
|
|
716
1458
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
717
1459
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
1460
|
+
var AUTH_ENV_KEYS = [
|
|
1461
|
+
"CLAUDE_CONFIG_DIR",
|
|
1462
|
+
"CODEWITH_HOME",
|
|
1463
|
+
"CODEX_HOME",
|
|
1464
|
+
"CURSOR_CONFIG_DIR",
|
|
1465
|
+
"OPENCODE_CONFIG_DIR",
|
|
1466
|
+
"AICOPILOT_CONFIG_DIR",
|
|
1467
|
+
"ANTHROPIC_API_KEY",
|
|
1468
|
+
"OPENAI_API_KEY",
|
|
1469
|
+
"OPENROUTER_API_KEY",
|
|
1470
|
+
"GITHUB_TOKEN",
|
|
1471
|
+
"GH_TOKEN",
|
|
1472
|
+
"XDG_CONFIG_HOME",
|
|
1473
|
+
"XDG_DATA_HOME",
|
|
1474
|
+
"XDG_STATE_HOME",
|
|
1475
|
+
"XDG_CACHE_HOME"
|
|
1476
|
+
];
|
|
718
1477
|
function appendBounded(current, chunk, maxBytes) {
|
|
719
1478
|
const next = current + chunk.toString("utf8");
|
|
720
1479
|
if (Buffer.byteLength(next, "utf8") <= maxBytes)
|
|
@@ -753,6 +1512,8 @@ function providerCommand(provider) {
|
|
|
753
1512
|
return "aicopilot";
|
|
754
1513
|
case "opencode":
|
|
755
1514
|
return "opencode";
|
|
1515
|
+
case "codex":
|
|
1516
|
+
return "codex";
|
|
756
1517
|
}
|
|
757
1518
|
}
|
|
758
1519
|
function agentArgs(target) {
|
|
@@ -789,6 +1550,16 @@ function agentArgs(target) {
|
|
|
789
1550
|
args.push("--agent", target.agent);
|
|
790
1551
|
args.push(...target.extraArgs ?? [], target.prompt);
|
|
791
1552
|
return args;
|
|
1553
|
+
case "codex":
|
|
1554
|
+
args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
|
|
1555
|
+
if (isolation === "safe")
|
|
1556
|
+
args.push("--ignore-rules");
|
|
1557
|
+
if (target.cwd)
|
|
1558
|
+
args.push("--cd", target.cwd);
|
|
1559
|
+
if (target.model)
|
|
1560
|
+
args.push("--model", target.model);
|
|
1561
|
+
args.push(...target.extraArgs ?? [], target.prompt);
|
|
1562
|
+
return args;
|
|
792
1563
|
case "aicopilot":
|
|
793
1564
|
args.push("run", "--format", "json");
|
|
794
1565
|
if (isolation === "safe")
|
|
@@ -815,8 +1586,7 @@ function agentArgs(target) {
|
|
|
815
1586
|
return args;
|
|
816
1587
|
}
|
|
817
1588
|
}
|
|
818
|
-
function commandSpec(
|
|
819
|
-
const target = loop.target;
|
|
1589
|
+
function commandSpec(target) {
|
|
820
1590
|
if (target.type === "command") {
|
|
821
1591
|
const commandTarget = target;
|
|
822
1592
|
return {
|
|
@@ -825,7 +1595,9 @@ function commandSpec(loop) {
|
|
|
825
1595
|
cwd: commandTarget.cwd,
|
|
826
1596
|
shell: commandTarget.shell,
|
|
827
1597
|
env: commandTarget.env,
|
|
828
|
-
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
1598
|
+
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1599
|
+
account: commandTarget.account,
|
|
1600
|
+
accountTool: commandTarget.account?.tool
|
|
829
1601
|
};
|
|
830
1602
|
}
|
|
831
1603
|
const agentTarget = target;
|
|
@@ -833,11 +1605,61 @@ function commandSpec(loop) {
|
|
|
833
1605
|
command: providerCommand(agentTarget.provider),
|
|
834
1606
|
args: agentArgs(agentTarget),
|
|
835
1607
|
cwd: agentTarget.cwd,
|
|
836
|
-
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
1608
|
+
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1609
|
+
account: agentTarget.account,
|
|
1610
|
+
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
|
|
837
1611
|
};
|
|
838
1612
|
}
|
|
839
|
-
|
|
840
|
-
const
|
|
1613
|
+
function executionEnv(spec, metadata, opts) {
|
|
1614
|
+
const env = { ...opts.env ?? process.env };
|
|
1615
|
+
if (spec.account) {
|
|
1616
|
+
const accountEnv = resolveAccountEnv(spec.account, spec.accountTool, env);
|
|
1617
|
+
for (const key of AUTH_ENV_KEYS)
|
|
1618
|
+
delete env[key];
|
|
1619
|
+
Object.assign(env, accountEnv);
|
|
1620
|
+
}
|
|
1621
|
+
Object.assign(env, spec.env ?? {});
|
|
1622
|
+
if (metadata.loopId)
|
|
1623
|
+
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1624
|
+
if (metadata.loopName)
|
|
1625
|
+
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1626
|
+
if (metadata.runId)
|
|
1627
|
+
env.LOOPS_RUN_ID = metadata.runId;
|
|
1628
|
+
if (metadata.scheduledFor)
|
|
1629
|
+
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1630
|
+
if (metadata.workflowId)
|
|
1631
|
+
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1632
|
+
if (metadata.workflowName)
|
|
1633
|
+
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1634
|
+
if (metadata.workflowRunId)
|
|
1635
|
+
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1636
|
+
if (metadata.workflowStepId)
|
|
1637
|
+
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1638
|
+
return env;
|
|
1639
|
+
}
|
|
1640
|
+
function commandExists(command, env) {
|
|
1641
|
+
if (command.includes("/") && existsSync2(command))
|
|
1642
|
+
return true;
|
|
1643
|
+
const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
|
|
1644
|
+
env,
|
|
1645
|
+
stdio: "ignore"
|
|
1646
|
+
});
|
|
1647
|
+
return (result.status ?? 1) === 0;
|
|
1648
|
+
}
|
|
1649
|
+
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1650
|
+
const spec = commandSpec(target);
|
|
1651
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1652
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1653
|
+
throw new Error(`Executable not found in PATH: ${spec.command}`);
|
|
1654
|
+
}
|
|
1655
|
+
return {
|
|
1656
|
+
command: spec.command,
|
|
1657
|
+
accountProfile: spec.account?.profile,
|
|
1658
|
+
accountTool: spec.accountTool
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1662
|
+
const spec = commandSpec(target);
|
|
841
1663
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
842
1664
|
const startedAt = nowIso();
|
|
843
1665
|
let stdout = "";
|
|
@@ -845,14 +1667,18 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
845
1667
|
let timedOut = false;
|
|
846
1668
|
let exitCode;
|
|
847
1669
|
let error;
|
|
848
|
-
const env =
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1670
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1671
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1672
|
+
return {
|
|
1673
|
+
status: "failed",
|
|
1674
|
+
stdout: "",
|
|
1675
|
+
stderr: "",
|
|
1676
|
+
error: `Executable not found in PATH: ${spec.command}`,
|
|
1677
|
+
startedAt,
|
|
1678
|
+
finishedAt: nowIso(),
|
|
1679
|
+
durationMs: 0
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
856
1682
|
const child = spawn(spec.command, spec.args, {
|
|
857
1683
|
cwd: spec.cwd,
|
|
858
1684
|
env,
|
|
@@ -860,6 +1686,16 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
860
1686
|
detached: true,
|
|
861
1687
|
stdio: ["ignore", "pipe", "pipe"]
|
|
862
1688
|
});
|
|
1689
|
+
if (child.pid)
|
|
1690
|
+
opts.onSpawn?.(child.pid);
|
|
1691
|
+
const abortHandler = () => {
|
|
1692
|
+
error = "cancelled";
|
|
1693
|
+
if (child.pid)
|
|
1694
|
+
killProcessGroup(child.pid);
|
|
1695
|
+
};
|
|
1696
|
+
if (opts.signal?.aborted)
|
|
1697
|
+
abortHandler();
|
|
1698
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
863
1699
|
child.stdout.on("data", (chunk) => {
|
|
864
1700
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
865
1701
|
});
|
|
@@ -882,6 +1718,7 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
882
1718
|
error = err instanceof Error ? err.message : String(err);
|
|
883
1719
|
} finally {
|
|
884
1720
|
clearTimeout(timer);
|
|
1721
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
885
1722
|
}
|
|
886
1723
|
const finishedAt = nowIso();
|
|
887
1724
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
@@ -922,12 +1759,243 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
922
1759
|
durationMs
|
|
923
1760
|
};
|
|
924
1761
|
}
|
|
1762
|
+
async function executeLoop(loop, run, opts = {}) {
|
|
1763
|
+
if (loop.target.type === "workflow") {
|
|
1764
|
+
throw new Error("workflow loop targets must be executed with executeLoopTarget");
|
|
1765
|
+
}
|
|
1766
|
+
return executeTarget(loop.target, {
|
|
1767
|
+
loopId: loop.id,
|
|
1768
|
+
loopName: loop.name,
|
|
1769
|
+
runId: run.id,
|
|
1770
|
+
scheduledFor: run.scheduledFor
|
|
1771
|
+
}, opts);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// src/lib/workflow-runner.ts
|
|
1775
|
+
function targetWithStepAccount(step) {
|
|
1776
|
+
const account = step.account ?? step.target.account;
|
|
1777
|
+
const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
|
|
1778
|
+
if (!account && timeoutMs === step.target.timeoutMs)
|
|
1779
|
+
return step.target;
|
|
1780
|
+
return { ...step.target, account, timeoutMs };
|
|
1781
|
+
}
|
|
1782
|
+
function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
|
|
1783
|
+
const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
|
|
1784
|
+
return {
|
|
1785
|
+
status: executorStatus,
|
|
1786
|
+
exitCode: executorStatus === "succeeded" ? 0 : 1,
|
|
1787
|
+
stdout,
|
|
1788
|
+
stderr: "",
|
|
1789
|
+
error,
|
|
1790
|
+
startedAt,
|
|
1791
|
+
finishedAt,
|
|
1792
|
+
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime()
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
async function executeWorkflow(store, workflow, opts = {}) {
|
|
1796
|
+
const run = store.createWorkflowRun({
|
|
1797
|
+
workflow,
|
|
1798
|
+
loop: opts.loop,
|
|
1799
|
+
loopRun: opts.loopRun,
|
|
1800
|
+
scheduledFor: opts.scheduledFor,
|
|
1801
|
+
idempotencyKey: opts.idempotencyKey
|
|
1802
|
+
});
|
|
1803
|
+
const startedAt = run.startedAt ?? nowIso();
|
|
1804
|
+
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
1805
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1806
|
+
return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
|
|
1807
|
+
}
|
|
1808
|
+
const ordered = workflowExecutionOrder(workflow);
|
|
1809
|
+
const byId = new Map(workflow.steps.map((step) => [step.id, step]));
|
|
1810
|
+
let blockingError;
|
|
1811
|
+
let terminalStatus = "succeeded";
|
|
1812
|
+
for (const step of ordered) {
|
|
1813
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1814
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1815
|
+
blockingError = "workflow run was cancelled";
|
|
1816
|
+
break;
|
|
1817
|
+
}
|
|
1818
|
+
const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1819
|
+
if (pendingTimeout) {
|
|
1820
|
+
terminalStatus = "timed_out";
|
|
1821
|
+
blockingError = pendingTimeout;
|
|
1822
|
+
break;
|
|
1823
|
+
}
|
|
1824
|
+
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1825
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
|
|
1826
|
+
continue;
|
|
1827
|
+
const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
|
|
1828
|
+
const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
|
|
1829
|
+
const dependencyStep = byId.get(dependencyId);
|
|
1830
|
+
if (dependencyRun?.status === "succeeded")
|
|
1831
|
+
return false;
|
|
1832
|
+
return !dependencyStep?.continueOnFailure;
|
|
1833
|
+
});
|
|
1834
|
+
if (blockedBy) {
|
|
1835
|
+
store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`);
|
|
1836
|
+
blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
|
|
1837
|
+
terminalStatus = "failed";
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id);
|
|
1841
|
+
if (startedStep.status !== "running") {
|
|
1842
|
+
terminalStatus = "failed";
|
|
1843
|
+
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
1844
|
+
break;
|
|
1845
|
+
}
|
|
1846
|
+
const metadata = {
|
|
1847
|
+
loopId: opts.loop?.id,
|
|
1848
|
+
loopName: opts.loop?.name,
|
|
1849
|
+
runId: opts.loopRun?.id,
|
|
1850
|
+
scheduledFor: opts.loopRun?.scheduledFor ?? opts.scheduledFor,
|
|
1851
|
+
workflowId: workflow.id,
|
|
1852
|
+
workflowName: workflow.name,
|
|
1853
|
+
workflowRunId: run.id,
|
|
1854
|
+
workflowStepId: step.id
|
|
1855
|
+
};
|
|
1856
|
+
let result;
|
|
1857
|
+
const controller = new AbortController;
|
|
1858
|
+
const externalAbort = () => controller.abort();
|
|
1859
|
+
if (opts.signal?.aborted)
|
|
1860
|
+
controller.abort();
|
|
1861
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1862
|
+
const cancelTimer = setInterval(() => {
|
|
1863
|
+
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
1864
|
+
controller.abort();
|
|
1865
|
+
}, opts.cancelPollMs ?? 500);
|
|
1866
|
+
cancelTimer.unref();
|
|
1867
|
+
try {
|
|
1868
|
+
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1869
|
+
...opts,
|
|
1870
|
+
signal: controller.signal,
|
|
1871
|
+
onSpawn: (pid) => {
|
|
1872
|
+
store.markWorkflowStepPid(run.id, step.id, pid);
|
|
1873
|
+
opts.onSpawn?.(pid);
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
const finishedAt2 = nowIso();
|
|
1878
|
+
result = {
|
|
1879
|
+
status: "failed",
|
|
1880
|
+
stdout: "",
|
|
1881
|
+
stderr: "",
|
|
1882
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1883
|
+
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
1884
|
+
finishedAt: finishedAt2,
|
|
1885
|
+
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
1886
|
+
};
|
|
1887
|
+
} finally {
|
|
1888
|
+
clearInterval(cancelTimer);
|
|
1889
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1890
|
+
}
|
|
1891
|
+
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1892
|
+
if (timeoutMessage && result.status === "failed") {
|
|
1893
|
+
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
1894
|
+
}
|
|
1895
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1896
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1897
|
+
blockingError = "workflow run was cancelled";
|
|
1898
|
+
break;
|
|
1899
|
+
}
|
|
1900
|
+
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1901
|
+
status: result.status,
|
|
1902
|
+
finishedAt: result.finishedAt,
|
|
1903
|
+
durationMs: result.durationMs,
|
|
1904
|
+
stdout: result.stdout,
|
|
1905
|
+
stderr: result.stderr,
|
|
1906
|
+
exitCode: result.exitCode,
|
|
1907
|
+
error: result.error
|
|
1908
|
+
});
|
|
1909
|
+
if (result.status !== "succeeded" && !step.continueOnFailure) {
|
|
1910
|
+
terminalStatus = result.status;
|
|
1911
|
+
blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
|
|
1912
|
+
break;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
if (terminalStatus !== "succeeded") {
|
|
1916
|
+
for (const step of ordered) {
|
|
1917
|
+
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1918
|
+
if (existing?.status === "pending" || existing?.status === "running") {
|
|
1919
|
+
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run");
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const finishedAt = nowIso();
|
|
1924
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1925
|
+
const terminalRun = store.requireWorkflowRun(run.id);
|
|
1926
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1927
|
+
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1928
|
+
}
|
|
1929
|
+
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1930
|
+
finishedAt,
|
|
1931
|
+
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
1932
|
+
error: blockingError
|
|
1933
|
+
});
|
|
1934
|
+
const steps = store.listWorkflowStepRuns(run.id);
|
|
1935
|
+
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
1936
|
+
}
|
|
1937
|
+
function preflightWorkflow(workflow, opts = {}) {
|
|
1938
|
+
return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
|
|
1939
|
+
workflowId: workflow.id,
|
|
1940
|
+
workflowName: workflow.name,
|
|
1941
|
+
workflowStepId: step.id
|
|
1942
|
+
}, opts));
|
|
1943
|
+
}
|
|
1944
|
+
async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
1945
|
+
if (loop.target.type !== "workflow")
|
|
1946
|
+
return executeLoop(loop, run, opts);
|
|
1947
|
+
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
1948
|
+
const controller = loop.target.timeoutMs ? new AbortController : undefined;
|
|
1949
|
+
let workflowTimedOut = false;
|
|
1950
|
+
const externalAbort = () => controller?.abort();
|
|
1951
|
+
if (controller && opts.signal?.aborted)
|
|
1952
|
+
controller.abort();
|
|
1953
|
+
if (controller)
|
|
1954
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1955
|
+
const timer = controller ? setTimeout(() => {
|
|
1956
|
+
workflowTimedOut = true;
|
|
1957
|
+
controller.abort();
|
|
1958
|
+
}, loop.target.timeoutMs) : undefined;
|
|
1959
|
+
timer?.unref();
|
|
1960
|
+
try {
|
|
1961
|
+
return await executeWorkflow(store, workflow, {
|
|
1962
|
+
...opts,
|
|
1963
|
+
signal: controller?.signal ?? opts.signal,
|
|
1964
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
|
|
1965
|
+
loop,
|
|
1966
|
+
loopRun: run,
|
|
1967
|
+
scheduledFor: run.scheduledFor,
|
|
1968
|
+
idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
|
|
1969
|
+
});
|
|
1970
|
+
} finally {
|
|
1971
|
+
if (timer)
|
|
1972
|
+
clearTimeout(timer);
|
|
1973
|
+
if (controller)
|
|
1974
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
925
1977
|
|
|
926
1978
|
// src/lib/scheduler.ts
|
|
1979
|
+
function manualRunScheduledFor(loop, now = new Date) {
|
|
1980
|
+
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1981
|
+
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1982
|
+
}
|
|
1983
|
+
return now.toISOString();
|
|
1984
|
+
}
|
|
1985
|
+
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
1986
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
1987
|
+
return false;
|
|
1988
|
+
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
1989
|
+
}
|
|
927
1990
|
function nextAfterRetry(loop, now) {
|
|
928
1991
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
929
1992
|
}
|
|
930
1993
|
function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
1994
|
+
if (run.status === "running")
|
|
1995
|
+
return;
|
|
1996
|
+
const current = store.getLoop(loop.id);
|
|
1997
|
+
if (!current || current.status !== "active")
|
|
1998
|
+
return;
|
|
931
1999
|
const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
|
|
932
2000
|
if (shouldRetry) {
|
|
933
2001
|
store.updateLoop(loop.id, {
|
|
@@ -944,21 +2012,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
|
944
2012
|
retryScheduledFor: undefined
|
|
945
2013
|
});
|
|
946
2014
|
}
|
|
947
|
-
async function
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
955
|
-
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
956
|
-
if (!claim)
|
|
957
|
-
return;
|
|
958
|
-
deps.onRun?.(claim.run);
|
|
2015
|
+
async function executeClaimedRun(deps) {
|
|
2016
|
+
let heartbeat;
|
|
2017
|
+
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
2018
|
+
heartbeat = setInterval(() => {
|
|
2019
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
|
|
2020
|
+
}, heartbeatEveryMs);
|
|
2021
|
+
heartbeat.unref();
|
|
959
2022
|
try {
|
|
960
|
-
const result = await (deps.execute ??
|
|
961
|
-
|
|
2023
|
+
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
2024
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
|
|
2025
|
+
})))(deps.loop, deps.run);
|
|
2026
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
962
2027
|
status: result.status,
|
|
963
2028
|
finishedAt: result.finishedAt,
|
|
964
2029
|
durationMs: result.durationMs,
|
|
@@ -967,25 +2032,53 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
967
2032
|
exitCode: result.exitCode,
|
|
968
2033
|
error: result.error,
|
|
969
2034
|
pid: result.pid
|
|
2035
|
+
}, {
|
|
2036
|
+
claimedBy: deps.runnerId,
|
|
2037
|
+
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
970
2038
|
});
|
|
971
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), result.status === "succeeded");
|
|
972
|
-
deps.onRun?.(finalRun);
|
|
973
|
-
return finalRun;
|
|
974
2039
|
} catch (err) {
|
|
975
|
-
deps.onError?.(
|
|
2040
|
+
deps.onError?.(deps.loop, err);
|
|
976
2041
|
const finishedAt = new Date;
|
|
977
|
-
|
|
2042
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
978
2043
|
status: "failed",
|
|
979
2044
|
finishedAt: finishedAt.toISOString(),
|
|
980
|
-
durationMs: finishedAt.getTime() - new Date(
|
|
2045
|
+
durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
|
|
981
2046
|
stdout: "",
|
|
982
2047
|
stderr: "",
|
|
983
2048
|
error: err instanceof Error ? err.message : String(err)
|
|
2049
|
+
}, {
|
|
2050
|
+
claimedBy: deps.runnerId,
|
|
2051
|
+
now: deps.now?.() ?? finishedAt
|
|
984
2052
|
});
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
2053
|
+
} finally {
|
|
2054
|
+
if (heartbeat)
|
|
2055
|
+
clearInterval(heartbeat);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
async function runSlot(deps, loop, scheduledFor) {
|
|
2059
|
+
const now = deps.now?.() ?? new Date;
|
|
2060
|
+
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2061
|
+
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
2062
|
+
advanceLoop(deps.store, loop, skipped, now, true);
|
|
2063
|
+
deps.onRun?.(skipped);
|
|
2064
|
+
return skipped;
|
|
988
2065
|
}
|
|
2066
|
+
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
2067
|
+
if (!claim)
|
|
2068
|
+
return;
|
|
2069
|
+
deps.onRun?.(claim.run);
|
|
2070
|
+
const finalRun = await executeClaimedRun({
|
|
2071
|
+
store: deps.store,
|
|
2072
|
+
runnerId: deps.runnerId,
|
|
2073
|
+
loop: claim.loop,
|
|
2074
|
+
run: claim.run,
|
|
2075
|
+
now: deps.now,
|
|
2076
|
+
execute: deps.execute,
|
|
2077
|
+
onError: deps.onError
|
|
2078
|
+
});
|
|
2079
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2080
|
+
deps.onRun?.(finalRun);
|
|
2081
|
+
return finalRun;
|
|
989
2082
|
}
|
|
990
2083
|
async function tick(deps) {
|
|
991
2084
|
const now = deps.now?.() ?? new Date;
|
|
@@ -1011,13 +2104,15 @@ async function tick(deps) {
|
|
|
1011
2104
|
skipped.push(run);
|
|
1012
2105
|
else
|
|
1013
2106
|
completed.push(run);
|
|
2107
|
+
if (["failed", "timed_out", "abandoned"].includes(run.status) && run.attempt < loop.maxAttempts)
|
|
2108
|
+
break;
|
|
1014
2109
|
}
|
|
1015
2110
|
}
|
|
1016
2111
|
return { claimed, completed, skipped, recovered, expired };
|
|
1017
2112
|
}
|
|
1018
2113
|
|
|
1019
2114
|
// src/daemon/control.ts
|
|
1020
|
-
import { existsSync, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2115
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
|
|
1021
2116
|
import { hostname } from "os";
|
|
1022
2117
|
import { dirname as dirname2 } from "path";
|
|
1023
2118
|
|
|
@@ -1045,7 +2140,7 @@ async function runLoop(opts) {
|
|
|
1045
2140
|
|
|
1046
2141
|
// src/daemon/control.ts
|
|
1047
2142
|
function readPid(path = pidFilePath()) {
|
|
1048
|
-
if (!
|
|
2143
|
+
if (!existsSync3(path))
|
|
1049
2144
|
return;
|
|
1050
2145
|
try {
|
|
1051
2146
|
const pid = Number(readFileSync(path, "utf8").trim());
|
|
@@ -1163,6 +2258,7 @@ async function runDaemon(opts = {}) {
|
|
|
1163
2258
|
const ownStore = !opts.store;
|
|
1164
2259
|
const store = opts.store ?? new Store;
|
|
1165
2260
|
const leaseId = genId();
|
|
2261
|
+
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
1166
2262
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
1167
2263
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
1168
2264
|
const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
|
|
@@ -1178,18 +2274,28 @@ async function runDaemon(opts = {}) {
|
|
|
1178
2274
|
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
|
|
1179
2275
|
let stopFlag = false;
|
|
1180
2276
|
let leaseLost = false;
|
|
2277
|
+
const runAbort = new AbortController;
|
|
2278
|
+
const requestStop = (message) => {
|
|
2279
|
+
stopFlag = true;
|
|
2280
|
+
if (!runAbort.signal.aborted)
|
|
2281
|
+
runAbort.abort();
|
|
2282
|
+
if (message)
|
|
2283
|
+
log(message);
|
|
2284
|
+
};
|
|
1181
2285
|
const ensureLease = () => {
|
|
1182
2286
|
const current = store.heartbeatDaemonLease(leaseId, leaseTtlMs);
|
|
1183
2287
|
if (!current || current.id !== leaseId) {
|
|
1184
2288
|
leaseLost = true;
|
|
1185
|
-
|
|
2289
|
+
requestStop("daemon lease lost");
|
|
1186
2290
|
throw new Error("daemon lease lost");
|
|
1187
2291
|
}
|
|
1188
2292
|
};
|
|
1189
2293
|
const onSignal = () => {
|
|
1190
|
-
|
|
1191
|
-
log("stop signal received");
|
|
2294
|
+
requestStop("stop signal received");
|
|
1192
2295
|
};
|
|
2296
|
+
if (opts.signal?.aborted)
|
|
2297
|
+
onSignal();
|
|
2298
|
+
opts.signal?.addEventListener("abort", onSignal, { once: true });
|
|
1193
2299
|
process.on("SIGINT", onSignal);
|
|
1194
2300
|
process.on("SIGTERM", onSignal);
|
|
1195
2301
|
try {
|
|
@@ -1202,7 +2308,7 @@ async function runDaemon(opts = {}) {
|
|
|
1202
2308
|
ensureLease();
|
|
1203
2309
|
const result = await tick({
|
|
1204
2310
|
store,
|
|
1205
|
-
runnerId
|
|
2311
|
+
runnerId,
|
|
1206
2312
|
execute: async (loop, run) => {
|
|
1207
2313
|
const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
|
|
1208
2314
|
const timer = setInterval(() => {
|
|
@@ -1214,7 +2320,10 @@ async function runDaemon(opts = {}) {
|
|
|
1214
2320
|
}, heartbeatMs);
|
|
1215
2321
|
timer.unref();
|
|
1216
2322
|
try {
|
|
1217
|
-
const result2 = await
|
|
2323
|
+
const result2 = await executeLoopTarget(store, loop, run, {
|
|
2324
|
+
signal: runAbort.signal,
|
|
2325
|
+
onSpawn: (pid) => store.markRunPid(run.id, pid, runnerId)
|
|
2326
|
+
});
|
|
1218
2327
|
if (leaseLost)
|
|
1219
2328
|
throw new Error("daemon lease lost during run");
|
|
1220
2329
|
return result2;
|
|
@@ -1231,6 +2340,7 @@ async function runDaemon(opts = {}) {
|
|
|
1231
2340
|
}
|
|
1232
2341
|
});
|
|
1233
2342
|
} finally {
|
|
2343
|
+
opts.signal?.removeEventListener("abort", onSignal);
|
|
1234
2344
|
process.off("SIGINT", onSignal);
|
|
1235
2345
|
process.off("SIGTERM", onSignal);
|
|
1236
2346
|
store.releaseDaemonLease(leaseId);
|
|
@@ -1266,6 +2376,7 @@ async function startDaemon(opts) {
|
|
|
1266
2376
|
|
|
1267
2377
|
// src/daemon/install.ts
|
|
1268
2378
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2379
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1269
2380
|
import { dirname as dirname3 } from "path";
|
|
1270
2381
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
1271
2382
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
@@ -1327,10 +2438,100 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
1327
2438
|
}
|
|
1328
2439
|
throw new Error(`startup install is not implemented for ${process.platform}`);
|
|
1329
2440
|
}
|
|
2441
|
+
function enableStartup(result) {
|
|
2442
|
+
const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
|
|
2443
|
+
return commands.map((command) => {
|
|
2444
|
+
const run = spawnSync3("sh", ["-c", command], {
|
|
2445
|
+
encoding: "utf8",
|
|
2446
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2447
|
+
});
|
|
2448
|
+
return {
|
|
2449
|
+
command,
|
|
2450
|
+
status: run.status,
|
|
2451
|
+
stdout: run.stdout.trim(),
|
|
2452
|
+
stderr: run.stderr.trim()
|
|
2453
|
+
};
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
// src/lib/doctor.ts
|
|
2458
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
2459
|
+
import { accessSync, constants } from "fs";
|
|
2460
|
+
var PROVIDER_COMMANDS = [
|
|
2461
|
+
"claude",
|
|
2462
|
+
"cursor-agent",
|
|
2463
|
+
"codewith",
|
|
2464
|
+
"aicopilot",
|
|
2465
|
+
"opencode",
|
|
2466
|
+
"codex"
|
|
2467
|
+
];
|
|
2468
|
+
function hasCommand(command) {
|
|
2469
|
+
const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
2470
|
+
return (result.status ?? 1) === 0;
|
|
2471
|
+
}
|
|
2472
|
+
function commandVersion(command) {
|
|
2473
|
+
const result = spawnSync4(command, ["--version"], {
|
|
2474
|
+
encoding: "utf8",
|
|
2475
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2476
|
+
});
|
|
2477
|
+
if ((result.status ?? 1) !== 0)
|
|
2478
|
+
return;
|
|
2479
|
+
return (result.stdout || result.stderr).trim().split(/\r?\n/)[0];
|
|
2480
|
+
}
|
|
2481
|
+
function runDoctor(store) {
|
|
2482
|
+
const checks = [];
|
|
2483
|
+
try {
|
|
2484
|
+
const dir = ensureDataDir();
|
|
2485
|
+
accessSync(dir, constants.R_OK | constants.W_OK);
|
|
2486
|
+
checks.push({ id: "data-dir", status: "ok", message: "data directory is writable", detail: dir });
|
|
2487
|
+
} catch (error) {
|
|
2488
|
+
checks.push({
|
|
2489
|
+
id: "data-dir",
|
|
2490
|
+
status: "fail",
|
|
2491
|
+
message: "data directory is not writable",
|
|
2492
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
const bunVersion = commandVersion("bun");
|
|
2496
|
+
checks.push(bunVersion ? { id: "bun", status: "ok", message: "bun is available", detail: bunVersion } : { id: "bun", status: "fail", message: "bun is not available on PATH" });
|
|
2497
|
+
const accountsVersion = commandVersion("accounts");
|
|
2498
|
+
checks.push(accountsVersion ? { id: "accounts", status: "ok", message: "accounts is available", detail: accountsVersion } : { id: "accounts", status: "warn", message: "accounts CLI is not available; account-routed steps will fail" });
|
|
2499
|
+
for (const command of PROVIDER_COMMANDS) {
|
|
2500
|
+
checks.push(hasCommand(command) ? { id: `provider:${command}`, status: "ok", message: `${command} is available` } : { id: `provider:${command}`, status: "warn", message: `${command} is not on PATH` });
|
|
2501
|
+
}
|
|
2502
|
+
const status = daemonStatus(store);
|
|
2503
|
+
checks.push(status.running ? { id: "daemon", status: "ok", message: `daemon is running pid=${status.pid}` } : { id: "daemon", status: status.stale ? "warn" : "ok", message: status.stale ? "daemon pid file is stale" : "daemon is not running" });
|
|
2504
|
+
const failedRuns = store.countRuns("failed");
|
|
2505
|
+
checks.push(failedRuns === 0 ? { id: "loop-runs", status: "ok", message: "no failed loop runs recorded" } : { id: "loop-runs", status: "warn", message: `${failedRuns} failed loop run(s) recorded` });
|
|
2506
|
+
for (const loop of store.listLoops({ status: "active" })) {
|
|
2507
|
+
try {
|
|
2508
|
+
if (loop.target.type === "workflow") {
|
|
2509
|
+
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
2510
|
+
for (const step of workflowExecutionOrder(workflow)) {
|
|
2511
|
+
preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id });
|
|
2512
|
+
}
|
|
2513
|
+
} else {
|
|
2514
|
+
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name });
|
|
2515
|
+
}
|
|
2516
|
+
checks.push({ id: `loop:${loop.id}:preflight`, status: "ok", message: `active loop target is ready: ${loop.name}` });
|
|
2517
|
+
} catch (error) {
|
|
2518
|
+
checks.push({
|
|
2519
|
+
id: `loop:${loop.id}:preflight`,
|
|
2520
|
+
status: "warn",
|
|
2521
|
+
message: `active loop target preflight failed: ${loop.name}`,
|
|
2522
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
return {
|
|
2527
|
+
ok: checks.every((check) => check.status !== "fail"),
|
|
2528
|
+
checks
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
1330
2531
|
|
|
1331
2532
|
// src/cli/index.ts
|
|
1332
2533
|
var program = new Command;
|
|
1333
|
-
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.
|
|
2534
|
+
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.0");
|
|
1334
2535
|
program.option("-j, --json", "print JSON");
|
|
1335
2536
|
function isJson() {
|
|
1336
2537
|
return Boolean(program.opts().json);
|
|
@@ -1412,8 +2613,16 @@ function baseCreateInput(name, opts, target) {
|
|
|
1412
2613
|
function addScheduleOptions(command) {
|
|
1413
2614
|
return command.option("--at <time>", "run once at an absolute time").option("--every <duration>", "run at a fixed interval, e.g. 15m, 1h, 30s").option("--cron <expr>", "run on a 5-field cron expression").option("--dynamic", "run on the default dynamic one-minute cadence").option("--catch-up <policy>", "none, latest, or all", "latest").option("--catch-up-limit <n>", "maximum missed slots to run when --catch-up all").option("--overlap <policy>", "skip or allow", "skip").option("--attempts <n>", "max attempts per scheduled slot").option("--retry-delay <duration>", "delay between retries", "1m").option("--lease <duration>", "running lease timeout", "30m").option("--expires-at <time>", "stop scheduling after this time").option("-d, --description <text>", "description");
|
|
1414
2615
|
}
|
|
2616
|
+
function addAccountOptions(command) {
|
|
2617
|
+
return command.option("--account <profile>", "OpenAccounts profile name for this target").option("--account-tool <tool>", "OpenAccounts tool id; defaults from provider for agents");
|
|
2618
|
+
}
|
|
2619
|
+
function accountFromOpts(opts) {
|
|
2620
|
+
if (!opts.account && opts.accountTool)
|
|
2621
|
+
throw new Error("--account-tool requires --account");
|
|
2622
|
+
return opts.account ? { profile: opts.account, tool: opts.accountTool } : undefined;
|
|
2623
|
+
}
|
|
1415
2624
|
var create = program.command("create").description("create loops");
|
|
1416
|
-
addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell")).action((name, opts) => {
|
|
2625
|
+
addAccountOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell"))).action((name, opts) => {
|
|
1417
2626
|
const store = new Store;
|
|
1418
2627
|
try {
|
|
1419
2628
|
const target = {
|
|
@@ -1421,7 +2630,8 @@ addScheduleOptions(create.command("command <name>").description("create a determ
|
|
|
1421
2630
|
command: opts.cmd,
|
|
1422
2631
|
cwd: opts.cwd,
|
|
1423
2632
|
shell: opts.shell,
|
|
1424
|
-
timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined
|
|
2633
|
+
timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined,
|
|
2634
|
+
account: accountFromOpts(opts)
|
|
1425
2635
|
};
|
|
1426
2636
|
const loop = store.createLoop(baseCreateInput(name, opts, target));
|
|
1427
2637
|
print(publicLoop(loop), `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`);
|
|
@@ -1429,9 +2639,9 @@ addScheduleOptions(create.command("command <name>").description("create a determ
|
|
|
1429
2639
|
store.close();
|
|
1430
2640
|
}
|
|
1431
2641
|
});
|
|
1432
|
-
addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, or
|
|
2642
|
+
addAccountOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe"))).action((name, opts) => {
|
|
1433
2643
|
const provider = opts.provider;
|
|
1434
|
-
if (!["claude", "cursor", "codewith", "aicopilot", "opencode"].includes(provider)) {
|
|
2644
|
+
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
|
|
1435
2645
|
throw new Error("unsupported provider");
|
|
1436
2646
|
}
|
|
1437
2647
|
if (!["safe", "none"].includes(opts.configIsolation)) {
|
|
@@ -1447,7 +2657,8 @@ addScheduleOptions(create.command("agent <name>").description("create a headless
|
|
|
1447
2657
|
model: opts.model,
|
|
1448
2658
|
agent: opts.agent,
|
|
1449
2659
|
timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined,
|
|
1450
|
-
configIsolation: opts.configIsolation
|
|
2660
|
+
configIsolation: opts.configIsolation,
|
|
2661
|
+
account: accountFromOpts(opts)
|
|
1451
2662
|
};
|
|
1452
2663
|
const loop = store.createLoop(baseCreateInput(name, opts, target));
|
|
1453
2664
|
print(publicLoop(loop), `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`);
|
|
@@ -1455,6 +2666,179 @@ addScheduleOptions(create.command("agent <name>").description("create a headless
|
|
|
1455
2666
|
store.close();
|
|
1456
2667
|
}
|
|
1457
2668
|
});
|
|
2669
|
+
addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name")).action((name, opts) => {
|
|
2670
|
+
const store = new Store;
|
|
2671
|
+
try {
|
|
2672
|
+
const workflow = store.requireWorkflow(opts.workflow);
|
|
2673
|
+
const target = {
|
|
2674
|
+
type: "workflow",
|
|
2675
|
+
workflowId: workflow.id
|
|
2676
|
+
};
|
|
2677
|
+
const loop = store.createLoop(baseCreateInput(name, opts, target));
|
|
2678
|
+
print(publicLoop(loop), `created workflow loop ${loop.id} (${loop.name}) workflow=${workflow.name} next=${loop.nextRunAt}`);
|
|
2679
|
+
} finally {
|
|
2680
|
+
store.close();
|
|
2681
|
+
}
|
|
2682
|
+
});
|
|
2683
|
+
var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
|
|
2684
|
+
workflows.command("validate <file>").description("validate a workflow JSON file without storing or running it").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables").action((file, opts) => {
|
|
2685
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync2(file, "utf8")), opts.name);
|
|
2686
|
+
const now = new Date().toISOString();
|
|
2687
|
+
const workflow = {
|
|
2688
|
+
id: "validation",
|
|
2689
|
+
name: body.name,
|
|
2690
|
+
description: body.description,
|
|
2691
|
+
version: body.version ?? 1,
|
|
2692
|
+
status: "active",
|
|
2693
|
+
steps: body.steps,
|
|
2694
|
+
createdAt: now,
|
|
2695
|
+
updatedAt: now
|
|
2696
|
+
};
|
|
2697
|
+
const preflight = opts.preflight ? preflightWorkflow(workflow) : undefined;
|
|
2698
|
+
print({ valid: true, workflow: publicWorkflow(workflow), preflight }, `valid workflow ${workflow.name} steps=${workflow.steps.length}`);
|
|
2699
|
+
});
|
|
2700
|
+
workflows.command("create <file>").description("validate and store a workflow JSON file").option("--name <name>", "override workflow name from the file").action((file, opts) => {
|
|
2701
|
+
const store = new Store;
|
|
2702
|
+
try {
|
|
2703
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync2(file, "utf8")), opts.name);
|
|
2704
|
+
const workflow = store.createWorkflow(body);
|
|
2705
|
+
print(publicWorkflow(workflow), `created workflow ${workflow.id} (${workflow.name}) steps=${workflow.steps.length}`);
|
|
2706
|
+
} finally {
|
|
2707
|
+
store.close();
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2710
|
+
workflows.command("list").alias("ls").option("--status <status>", "active or archived", "active").action((opts) => {
|
|
2711
|
+
const store = new Store;
|
|
2712
|
+
try {
|
|
2713
|
+
const workflowsList = store.listWorkflows({ status: opts.status });
|
|
2714
|
+
if (isJson())
|
|
2715
|
+
print(workflowsList.map(publicWorkflow));
|
|
2716
|
+
else {
|
|
2717
|
+
for (const workflow of workflowsList) {
|
|
2718
|
+
console.log(`${workflow.id} ${workflow.status.padEnd(8)} steps=${workflow.steps.length} ${workflow.name}`);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
} finally {
|
|
2722
|
+
store.close();
|
|
2723
|
+
}
|
|
2724
|
+
});
|
|
2725
|
+
workflows.command("show <idOrName>").action((idOrName) => {
|
|
2726
|
+
const store = new Store;
|
|
2727
|
+
try {
|
|
2728
|
+
print(publicWorkflow(store.requireWorkflow(idOrName)));
|
|
2729
|
+
} finally {
|
|
2730
|
+
store.close();
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
workflows.command("inspect <runId>").description("show a workflow run with steps and events").action((runId) => {
|
|
2734
|
+
const store = new Store;
|
|
2735
|
+
try {
|
|
2736
|
+
const run = store.requireWorkflowRun(runId);
|
|
2737
|
+
const steps = store.listWorkflowStepRuns(run.id);
|
|
2738
|
+
const events = store.listWorkflowEvents(run.id);
|
|
2739
|
+
const value = {
|
|
2740
|
+
workflowRun: publicWorkflowRun(run),
|
|
2741
|
+
steps: steps.map((step) => publicWorkflowStepRun(step, isJson())),
|
|
2742
|
+
events: events.map(publicWorkflowEvent)
|
|
2743
|
+
};
|
|
2744
|
+
if (isJson())
|
|
2745
|
+
print(value);
|
|
2746
|
+
else {
|
|
2747
|
+
console.log(`${run.id} ${run.status} ${run.workflowName}`);
|
|
2748
|
+
for (const step of steps) {
|
|
2749
|
+
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
|
|
2750
|
+
}
|
|
2751
|
+
console.log(` events=${events.length}`);
|
|
2752
|
+
}
|
|
2753
|
+
} finally {
|
|
2754
|
+
store.close();
|
|
2755
|
+
}
|
|
2756
|
+
});
|
|
2757
|
+
workflows.command("run <idOrName>").option("--show-output", "show step stdout/stderr").action(async (idOrName, opts) => {
|
|
2758
|
+
const store = new Store;
|
|
2759
|
+
try {
|
|
2760
|
+
const workflow = store.requireWorkflow(idOrName);
|
|
2761
|
+
const result = await executeWorkflow(store, workflow);
|
|
2762
|
+
const run = store.listWorkflowRuns({ workflowId: workflow.id, limit: 1 })[0];
|
|
2763
|
+
const steps = run ? store.listWorkflowStepRuns(run.id) : [];
|
|
2764
|
+
const value = {
|
|
2765
|
+
result,
|
|
2766
|
+
workflowRun: run ? publicWorkflowRun(run) : undefined,
|
|
2767
|
+
steps: steps.map((step) => publicWorkflowStepRun(step, opts.showOutput))
|
|
2768
|
+
};
|
|
2769
|
+
if (isJson())
|
|
2770
|
+
print(value);
|
|
2771
|
+
else {
|
|
2772
|
+
console.log(`${run?.id ?? workflow.id} ${result.status}`);
|
|
2773
|
+
for (const step of steps) {
|
|
2774
|
+
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
} finally {
|
|
2778
|
+
store.close();
|
|
2779
|
+
}
|
|
2780
|
+
});
|
|
2781
|
+
workflows.command("runs [idOrName]").option("--limit <n>", "limit", "50").action((idOrName, opts) => {
|
|
2782
|
+
const store = new Store;
|
|
2783
|
+
try {
|
|
2784
|
+
const workflow = idOrName ? store.requireWorkflow(idOrName) : undefined;
|
|
2785
|
+
const runs = store.listWorkflowRuns({ workflowId: workflow?.id, limit: Number(opts.limit) });
|
|
2786
|
+
if (isJson())
|
|
2787
|
+
print(runs.map(publicWorkflowRun));
|
|
2788
|
+
else {
|
|
2789
|
+
for (const run of runs) {
|
|
2790
|
+
console.log(`${run.id} ${run.status.padEnd(10)} ${run.workflowName} started=${run.startedAt ?? "-"}`);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
} finally {
|
|
2794
|
+
store.close();
|
|
2795
|
+
}
|
|
2796
|
+
});
|
|
2797
|
+
workflows.command("events <runId>").option("--limit <n>", "limit", "200").action((runId, opts) => {
|
|
2798
|
+
const store = new Store;
|
|
2799
|
+
try {
|
|
2800
|
+
const events = store.listWorkflowEvents(runId, Number(opts.limit));
|
|
2801
|
+
if (isJson())
|
|
2802
|
+
print(events.map(publicWorkflowEvent));
|
|
2803
|
+
else {
|
|
2804
|
+
for (const event of events) {
|
|
2805
|
+
console.log(`${String(event.sequence).padStart(3, "0")} ${event.eventType.padEnd(14)} ${event.stepId ?? "-"} ${event.createdAt}`);
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
} finally {
|
|
2809
|
+
store.close();
|
|
2810
|
+
}
|
|
2811
|
+
});
|
|
2812
|
+
workflows.command("cancel <runId>").description("mark a workflow run cancelled and cancel pending/running steps").option("--reason <reason>", "cancellation reason", "cancelled by user").action((runId, opts) => {
|
|
2813
|
+
const store = new Store;
|
|
2814
|
+
try {
|
|
2815
|
+
const run = store.cancelWorkflowRun(runId, opts.reason);
|
|
2816
|
+
print(publicWorkflowRun(run), `${run.id} ${run.status}`);
|
|
2817
|
+
} finally {
|
|
2818
|
+
store.close();
|
|
2819
|
+
}
|
|
2820
|
+
});
|
|
2821
|
+
workflows.command("recover <runId>").description("reset interrupted running workflow steps to pending").option("--reason <reason>", "recovery reason", "manual recovery").action((runId, opts) => {
|
|
2822
|
+
const store = new Store;
|
|
2823
|
+
try {
|
|
2824
|
+
const result = store.recoverWorkflowRun(runId, opts.reason);
|
|
2825
|
+
print({
|
|
2826
|
+
workflowRun: publicWorkflowRun(result.run),
|
|
2827
|
+
recoveredSteps: result.recoveredSteps.map((step) => publicWorkflowStepRun(step))
|
|
2828
|
+
}, `${result.run.id} recovered=${result.recoveredSteps.length}`);
|
|
2829
|
+
} finally {
|
|
2830
|
+
store.close();
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2833
|
+
workflows.command("archive <idOrName>").action((idOrName) => {
|
|
2834
|
+
const store = new Store;
|
|
2835
|
+
try {
|
|
2836
|
+
const workflow = store.archiveWorkflow(idOrName);
|
|
2837
|
+
print(publicWorkflow(workflow), `${workflow.id} ${workflow.status}`);
|
|
2838
|
+
} finally {
|
|
2839
|
+
store.close();
|
|
2840
|
+
}
|
|
2841
|
+
});
|
|
1458
2842
|
program.command("list").alias("ls").option("--status <status>", "filter by status").action((opts) => {
|
|
1459
2843
|
const store = new Store;
|
|
1460
2844
|
try {
|
|
@@ -1520,20 +2904,17 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
|
|
|
1520
2904
|
const store = new Store;
|
|
1521
2905
|
try {
|
|
1522
2906
|
const loop = store.requireLoop(idOrName);
|
|
1523
|
-
const
|
|
2907
|
+
const runnerId = `manual:${process.pid}`;
|
|
2908
|
+
const now = new Date;
|
|
2909
|
+
const scheduledFor = manualRunScheduledFor(loop, now);
|
|
2910
|
+
const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
|
|
2911
|
+
const claim = store.claimRun(loop, scheduledFor, runnerId, now);
|
|
1524
2912
|
if (!claim)
|
|
1525
2913
|
throw new Error("could not claim manual run");
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
durationMs: result.durationMs,
|
|
1531
|
-
stdout: result.stdout,
|
|
1532
|
-
stderr: result.stderr,
|
|
1533
|
-
exitCode: result.exitCode,
|
|
1534
|
-
error: result.error,
|
|
1535
|
-
pid: result.pid
|
|
1536
|
-
});
|
|
2914
|
+
const run = await executeClaimedRun({ store, runnerId, loop: claim.loop, run: claim.run });
|
|
2915
|
+
if (shouldAdvance) {
|
|
2916
|
+
advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
|
|
2917
|
+
}
|
|
1537
2918
|
print(publicRun(run, opts.showOutput), `${run.id} ${run.status}`);
|
|
1538
2919
|
} finally {
|
|
1539
2920
|
store.close();
|
|
@@ -1548,6 +2929,24 @@ program.command("tick").description("run one scheduler tick").action(async () =>
|
|
|
1548
2929
|
store.close();
|
|
1549
2930
|
}
|
|
1550
2931
|
});
|
|
2932
|
+
program.command("doctor").description("check local OpenLoops runtime dependencies and state").action(() => {
|
|
2933
|
+
const store = new Store;
|
|
2934
|
+
try {
|
|
2935
|
+
const report = runDoctor(store);
|
|
2936
|
+
if (isJson())
|
|
2937
|
+
print(report);
|
|
2938
|
+
else {
|
|
2939
|
+
for (const check of report.checks) {
|
|
2940
|
+
const marker = check.status === "ok" ? "ok" : check.status === "warn" ? "warn" : "fail";
|
|
2941
|
+
console.log(`${marker.padEnd(4)} ${check.id.padEnd(22)} ${check.message}${check.detail ? ` (${check.detail})` : ""}`);
|
|
2942
|
+
}
|
|
2943
|
+
if (!report.ok)
|
|
2944
|
+
process.exitCode = 1;
|
|
2945
|
+
}
|
|
2946
|
+
} finally {
|
|
2947
|
+
store.close();
|
|
2948
|
+
}
|
|
2949
|
+
});
|
|
1551
2950
|
var daemon = program.command("daemon").description("manage the local daemon");
|
|
1552
2951
|
daemon.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
|
|
1553
2952
|
daemon.command("start").action(async () => {
|
|
@@ -1566,15 +2965,20 @@ daemon.command("status").action(() => {
|
|
|
1566
2965
|
store.close();
|
|
1567
2966
|
}
|
|
1568
2967
|
});
|
|
1569
|
-
daemon.command("install").description("write a systemd user service or launchd plist").action(() => {
|
|
2968
|
+
daemon.command("install").description("write a systemd user service or launchd plist").option("--enable", "also enable/start the user service when supported").action((opts) => {
|
|
1570
2969
|
const result = installStartup(process.argv[1] ?? "loops");
|
|
2970
|
+
if (opts.enable)
|
|
2971
|
+
result.enableResults = enableStartup(result);
|
|
2972
|
+
const enableText = result.enableResults ? `
|
|
2973
|
+
${result.enableResults.map((item) => `${item.command} -> ${item.status === 0 ? "ok" : `exit ${item.status}`}`).join(`
|
|
2974
|
+
`)}` : "";
|
|
1571
2975
|
print(result, `wrote ${result.path}
|
|
1572
2976
|
${result.instructions.join(`
|
|
1573
|
-
`)}`);
|
|
2977
|
+
`)}${enableText}`);
|
|
1574
2978
|
});
|
|
1575
2979
|
daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
|
|
1576
2980
|
const path = daemonLogPath();
|
|
1577
|
-
if (!
|
|
2981
|
+
if (!existsSync4(path)) {
|
|
1578
2982
|
console.log("");
|
|
1579
2983
|
return;
|
|
1580
2984
|
}
|