@hasna/loops 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -4
- package/dist/cli/index.js +686 -88
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/index.js +474 -71
- package/dist/daemon/install.d.ts +8 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +644 -79
- package/dist/lib/doctor.d.ts +13 -0
- package/dist/lib/env.d.ts +4 -0
- package/dist/lib/executor.d.ts +8 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/scheduler.d.ts +12 -0
- package/dist/lib/store.d.ts +10 -0
- package/dist/lib/store.js +157 -20
- package/dist/lib/workflow-runner.d.ts +4 -1
- package/dist/sdk/index.js +440 -79
- package/dist/types.d.ts +3 -2
- package/docs/USAGE.md +25 -4
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
export type DoctorSeverity = "ok" | "warn" | "fail";
|
|
3
|
+
export interface DoctorCheck {
|
|
4
|
+
id: string;
|
|
5
|
+
status: DoctorSeverity;
|
|
6
|
+
message: string;
|
|
7
|
+
detail?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DoctorReport {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
checks: DoctorCheck[];
|
|
12
|
+
}
|
|
13
|
+
export declare function runDoctor(store: Store): DoctorReport;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function commonExecutableDirs(env?: NodeJS.ProcessEnv): string[];
|
|
2
|
+
export declare function normalizeExecutionPath(env?: NodeJS.ProcessEnv): string;
|
|
3
|
+
export declare function executableExists(command: string, env?: NodeJS.ProcessEnv): boolean;
|
|
4
|
+
export declare function commandNotFoundMessage(command: string, env?: NodeJS.ProcessEnv): string;
|
package/dist/lib/executor.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface ExecuteOptions {
|
|
|
3
3
|
maxOutputBytes?: number;
|
|
4
4
|
env?: NodeJS.ProcessEnv;
|
|
5
5
|
log?: (message: string) => void;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
onSpawn?: (pid: number) => void;
|
|
6
8
|
}
|
|
7
9
|
export interface ExecutionMetadata {
|
|
8
10
|
loopId?: string;
|
|
@@ -14,5 +16,11 @@ export interface ExecutionMetadata {
|
|
|
14
16
|
workflowRunId?: string;
|
|
15
17
|
workflowStepId?: string;
|
|
16
18
|
}
|
|
19
|
+
export interface PreflightResult {
|
|
20
|
+
command: string;
|
|
21
|
+
accountProfile?: string;
|
|
22
|
+
accountTool?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function preflightTarget(target: ExecutableTarget, metadata?: ExecutionMetadata, opts?: ExecuteOptions): PreflightResult;
|
|
17
25
|
export declare function executeTarget(target: ExecutableTarget, metadata?: ExecutionMetadata, opts?: ExecuteOptions): Promise<ExecutorResult>;
|
|
18
26
|
export declare function executeLoop(loop: Loop, run: LoopRun, opts?: ExecuteOptions): Promise<ExecutorResult>;
|
package/dist/lib/format.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Loop, LoopRun, WorkflowEvent, WorkflowRun, WorkflowSpec, WorkflowStepRun } from "../types.js";
|
|
2
2
|
export declare function redact(value: string | undefined, visible?: number): string | undefined;
|
|
3
|
+
export declare function textOutputBlocks(value: Pick<LoopRun | WorkflowStepRun, "stdout" | "stderr">, opts?: {
|
|
4
|
+
indent?: string;
|
|
5
|
+
}): string[];
|
|
3
6
|
export declare function publicLoop(loop: Loop): Record<string, unknown>;
|
|
4
7
|
export declare function publicRun(run: LoopRun, showOutput?: boolean): Record<string, unknown>;
|
|
5
8
|
export declare function publicWorkflow(workflow: WorkflowSpec): Record<string, unknown>;
|
package/dist/lib/scheduler.d.ts
CHANGED
|
@@ -15,4 +15,16 @@ export interface TickResult {
|
|
|
15
15
|
recovered: LoopRun[];
|
|
16
16
|
expired: Loop[];
|
|
17
17
|
}
|
|
18
|
+
export declare function manualRunScheduledFor(loop: Loop, now?: Date): string;
|
|
19
|
+
export declare function shouldAdvanceManualRun(loop: Loop, scheduledFor: string, now?: Date): boolean;
|
|
20
|
+
export declare function advanceLoop(store: Store, loop: Loop, run: LoopRun, finishedAt: Date, succeeded: boolean): void;
|
|
21
|
+
export declare function executeClaimedRun(deps: {
|
|
22
|
+
store: Store;
|
|
23
|
+
runnerId: string;
|
|
24
|
+
loop: Loop;
|
|
25
|
+
run: LoopRun;
|
|
26
|
+
now?: () => Date;
|
|
27
|
+
execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
|
|
28
|
+
onError?: (loop: Loop, error: unknown) => void;
|
|
29
|
+
}): Promise<LoopRun>;
|
|
18
30
|
export declare function tick(deps: SchedulerDeps): Promise<TickResult>;
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -45,6 +45,7 @@ export declare class Store {
|
|
|
45
45
|
archiveWorkflow(idOrName: string): WorkflowSpec;
|
|
46
46
|
createWorkflowRun(input: CreateWorkflowRunInput): WorkflowRun;
|
|
47
47
|
getWorkflowRun(id: string): WorkflowRun | undefined;
|
|
48
|
+
requireWorkflowRun(id: string): WorkflowRun;
|
|
48
49
|
listWorkflowRuns(opts?: {
|
|
49
50
|
workflowId?: string;
|
|
50
51
|
loopRunId?: string;
|
|
@@ -52,13 +53,22 @@ export declare class Store {
|
|
|
52
53
|
}): WorkflowRun[];
|
|
53
54
|
listWorkflowStepRuns(workflowRunId: string): WorkflowStepRun[];
|
|
54
55
|
getWorkflowStepRun(workflowRunId: string, stepId: string): WorkflowStepRun | undefined;
|
|
56
|
+
isWorkflowRunTerminal(workflowRunId: string): boolean;
|
|
55
57
|
startWorkflowStepRun(workflowRunId: string, stepId: string): WorkflowStepRun;
|
|
58
|
+
markWorkflowStepPid(workflowRunId: string, stepId: string, pid: number): WorkflowStepRun;
|
|
59
|
+
recoverWorkflowRun(workflowRunId: string, reason?: string): {
|
|
60
|
+
run: WorkflowRun;
|
|
61
|
+
recoveredSteps: WorkflowStepRun[];
|
|
62
|
+
};
|
|
56
63
|
finalizeWorkflowStepRun(workflowRunId: string, stepId: string, patch: Pick<WorkflowStepRun, "status" | "finishedAt" | "durationMs" | "stdout" | "stderr"> & Partial<Pick<WorkflowStepRun, "exitCode" | "error">>): WorkflowStepRun;
|
|
57
64
|
skipWorkflowStepRun(workflowRunId: string, stepId: string, reason: string): WorkflowStepRun;
|
|
58
65
|
finalizeWorkflowRun(workflowRunId: string, status: WorkflowRunStatus, patch?: Partial<Pick<WorkflowRun, "finishedAt" | "durationMs" | "error">>): WorkflowRun;
|
|
66
|
+
cancelWorkflowRun(workflowRunId: string, reason?: string): WorkflowRun;
|
|
59
67
|
appendWorkflowEvent(workflowRunId: string, eventType: string, stepId?: string, payload?: Record<string, unknown>): WorkflowEvent;
|
|
60
68
|
listWorkflowEvents(workflowRunId: string, limit?: number): WorkflowEvent[];
|
|
61
69
|
hasRunningRun(loopId: string): boolean;
|
|
70
|
+
markRunPid(id: string, pid: number, claimedBy?: string): LoopRun | undefined;
|
|
71
|
+
private hasLiveWorkflowStepProcesses;
|
|
62
72
|
createSkippedRun(loop: Loop, scheduledFor: string, reason: string): LoopRun;
|
|
63
73
|
getRun(id: string): LoopRun | undefined;
|
|
64
74
|
getRunBySlot(loopId: string, scheduledFor: string): LoopRun | undefined;
|
package/dist/lib/store.js
CHANGED
|
@@ -247,6 +247,9 @@ function validateTarget(value, label) {
|
|
|
247
247
|
assertObject(value, label);
|
|
248
248
|
if (value.type === "command") {
|
|
249
249
|
assertString(value.command, `${label}.command`);
|
|
250
|
+
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
251
|
+
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
252
|
+
}
|
|
250
253
|
return value;
|
|
251
254
|
}
|
|
252
255
|
if (value.type === "agent") {
|
|
@@ -412,6 +415,7 @@ function rowToWorkflowStepRun(row) {
|
|
|
412
415
|
startedAt: row.started_at ?? undefined,
|
|
413
416
|
finishedAt: row.finished_at ?? undefined,
|
|
414
417
|
exitCode: row.exit_code ?? undefined,
|
|
418
|
+
pid: row.pid ?? undefined,
|
|
415
419
|
durationMs: row.duration_ms ?? undefined,
|
|
416
420
|
stdout: row.stdout ?? undefined,
|
|
417
421
|
stderr: row.stderr ?? undefined,
|
|
@@ -433,6 +437,14 @@ function rowToWorkflowEvent(row) {
|
|
|
433
437
|
createdAt: row.created_at
|
|
434
438
|
};
|
|
435
439
|
}
|
|
440
|
+
function isProcessAlive(pid) {
|
|
441
|
+
try {
|
|
442
|
+
process.kill(pid, 0);
|
|
443
|
+
return true;
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
436
448
|
function rowToLease(row) {
|
|
437
449
|
return {
|
|
438
450
|
id: row.id,
|
|
@@ -565,6 +577,7 @@ class Store {
|
|
|
565
577
|
started_at TEXT,
|
|
566
578
|
finished_at TEXT,
|
|
567
579
|
exit_code INTEGER,
|
|
580
|
+
pid INTEGER,
|
|
568
581
|
duration_ms INTEGER,
|
|
569
582
|
stdout TEXT,
|
|
570
583
|
stderr TEXT,
|
|
@@ -590,6 +603,9 @@ class Store {
|
|
|
590
603
|
);
|
|
591
604
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
592
605
|
`);
|
|
606
|
+
try {
|
|
607
|
+
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
608
|
+
} catch {}
|
|
593
609
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
594
610
|
}
|
|
595
611
|
createLoop(input, from = new Date) {
|
|
@@ -770,8 +786,8 @@ class Store {
|
|
|
770
786
|
input.workflow.steps.forEach((step, sequence) => {
|
|
771
787
|
const account = step.account ?? step.target.account;
|
|
772
788
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
773
|
-
exit_code, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
774
|
-
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
789
|
+
exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
790
|
+
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
775
791
|
$accountProfile, $accountTool, $created, $updated)`).run({
|
|
776
792
|
$id: genId(),
|
|
777
793
|
$workflowRunId: runId,
|
|
@@ -812,6 +828,12 @@ class Store {
|
|
|
812
828
|
const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
|
|
813
829
|
return row ? rowToWorkflowRun(row) : undefined;
|
|
814
830
|
}
|
|
831
|
+
requireWorkflowRun(id) {
|
|
832
|
+
const run = this.getWorkflowRun(id);
|
|
833
|
+
if (!run)
|
|
834
|
+
throw new Error(`workflow run not found: ${id}`);
|
|
835
|
+
return run;
|
|
836
|
+
}
|
|
815
837
|
listWorkflowRuns(opts = {}) {
|
|
816
838
|
const limit = opts.limit ?? 100;
|
|
817
839
|
let rows;
|
|
@@ -832,23 +854,67 @@ class Store {
|
|
|
832
854
|
const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
|
|
833
855
|
return row ? rowToWorkflowStepRun(row) : undefined;
|
|
834
856
|
}
|
|
857
|
+
isWorkflowRunTerminal(workflowRunId) {
|
|
858
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
859
|
+
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
860
|
+
}
|
|
835
861
|
startWorkflowStepRun(workflowRunId, stepId) {
|
|
836
862
|
const now = nowIso();
|
|
837
|
-
this.db.query(`UPDATE workflow_step_runs
|
|
863
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
838
864
|
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
839
|
-
stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
840
|
-
WHERE workflow_run_id=$workflowRunId
|
|
841
|
-
|
|
865
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
866
|
+
WHERE workflow_run_id=$workflowRunId
|
|
867
|
+
AND step_id=$stepId
|
|
868
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
869
|
+
AND EXISTS (
|
|
870
|
+
SELECT 1 FROM workflow_runs
|
|
871
|
+
WHERE id=$workflowRunId AND status='running'
|
|
872
|
+
)`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
|
|
842
873
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
843
874
|
if (!run)
|
|
844
875
|
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
876
|
+
if (res.changes !== 1) {
|
|
877
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
878
|
+
}
|
|
879
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
880
|
+
return run;
|
|
881
|
+
}
|
|
882
|
+
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
883
|
+
const now = nowIso();
|
|
884
|
+
this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
|
|
885
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
|
|
886
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
887
|
+
if (!run)
|
|
888
|
+
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
845
889
|
return run;
|
|
846
890
|
}
|
|
891
|
+
recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
|
|
892
|
+
const now = nowIso();
|
|
893
|
+
const before = this.listWorkflowStepRuns(workflowRunId).filter((step) => step.status === "running");
|
|
894
|
+
const live = before.filter((step) => step.pid !== undefined && isProcessAlive(step.pid));
|
|
895
|
+
if (live.length > 0) {
|
|
896
|
+
throw new Error(`cannot recover workflow run while step processes are still alive: ${live.map((step) => `${step.stepId} pid=${step.pid}`).join(", ")}`);
|
|
897
|
+
}
|
|
898
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
899
|
+
SET status='pending', started_at=NULL, finished_at=NULL, exit_code=NULL, pid=NULL, duration_ms=NULL,
|
|
900
|
+
stdout=NULL, stderr=NULL, error=$reason, updated_at=$updated
|
|
901
|
+
WHERE workflow_run_id=$workflowRunId AND status='running'`).run({ $workflowRunId: workflowRunId, $reason: reason, $updated: now });
|
|
902
|
+
if (before.length > 0) {
|
|
903
|
+
this.appendWorkflowEvent(workflowRunId, "recovered", undefined, {
|
|
904
|
+
reason,
|
|
905
|
+
recoveredSteps: before.map((step) => step.stepId)
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
run: this.requireWorkflowRun(workflowRunId),
|
|
910
|
+
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
911
|
+
};
|
|
912
|
+
}
|
|
847
913
|
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
848
914
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
849
|
-
this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
850
|
-
stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
851
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({
|
|
915
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
916
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
917
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
|
|
852
918
|
$workflowRunId: workflowRunId,
|
|
853
919
|
$stepId: stepId,
|
|
854
920
|
$status: patch.status,
|
|
@@ -860,10 +926,12 @@ class Store {
|
|
|
860
926
|
$error: patch.error ?? null,
|
|
861
927
|
$updated: finishedAt
|
|
862
928
|
});
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
929
|
+
if (res.changes === 1) {
|
|
930
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
931
|
+
exitCode: patch.exitCode,
|
|
932
|
+
error: patch.error
|
|
933
|
+
});
|
|
934
|
+
}
|
|
867
935
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
868
936
|
if (!run)
|
|
869
937
|
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
@@ -871,9 +939,10 @@ class Store {
|
|
|
871
939
|
}
|
|
872
940
|
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
873
941
|
const now = nowIso();
|
|
874
|
-
this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, error=$error, updated_at=$updated
|
|
875
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
|
|
876
|
-
|
|
942
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
943
|
+
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 });
|
|
944
|
+
if (res.changes === 1)
|
|
945
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
877
946
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
878
947
|
if (!run)
|
|
879
948
|
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
@@ -881,8 +950,8 @@ class Store {
|
|
|
881
950
|
}
|
|
882
951
|
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
883
952
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
884
|
-
this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
885
|
-
WHERE id=$id`).run({
|
|
953
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
954
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
|
|
886
955
|
$id: workflowRunId,
|
|
887
956
|
$status: status,
|
|
888
957
|
$finished: finishedAt,
|
|
@@ -890,12 +959,36 @@ class Store {
|
|
|
890
959
|
$error: patch.error ?? null,
|
|
891
960
|
$updated: finishedAt
|
|
892
961
|
});
|
|
893
|
-
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
894
962
|
const run = this.getWorkflowRun(workflowRunId);
|
|
895
963
|
if (!run)
|
|
896
964
|
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
965
|
+
if (res.changes === 1)
|
|
966
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
897
967
|
return run;
|
|
898
968
|
}
|
|
969
|
+
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
970
|
+
const now = nowIso();
|
|
971
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
972
|
+
try {
|
|
973
|
+
const run = this.requireWorkflowRun(workflowRunId);
|
|
974
|
+
if (!["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)) {
|
|
975
|
+
this.db.query(`UPDATE workflow_runs
|
|
976
|
+
SET status='cancelled', finished_at=$finished, error=$reason, updated_at=$updated
|
|
977
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
978
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
979
|
+
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
980
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
981
|
+
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
982
|
+
}
|
|
983
|
+
this.db.exec("COMMIT");
|
|
984
|
+
return this.requireWorkflowRun(workflowRunId);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
try {
|
|
987
|
+
this.db.exec("ROLLBACK");
|
|
988
|
+
} catch {}
|
|
989
|
+
throw error;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
899
992
|
appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
|
|
900
993
|
const now = nowIso();
|
|
901
994
|
const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
|
|
@@ -924,6 +1017,24 @@ class Store {
|
|
|
924
1017
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
925
1018
|
return (row?.count ?? 0) > 0;
|
|
926
1019
|
}
|
|
1020
|
+
markRunPid(id, pid, claimedBy) {
|
|
1021
|
+
const now = nowIso();
|
|
1022
|
+
const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1023
|
+
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 });
|
|
1024
|
+
if (res.changes !== 1)
|
|
1025
|
+
return;
|
|
1026
|
+
return this.getRun(id);
|
|
1027
|
+
}
|
|
1028
|
+
hasLiveWorkflowStepProcesses(loopRunId) {
|
|
1029
|
+
const liveWorkflowSteps = this.db.query(`SELECT wr.id AS workflow_run_id, wsr.step_id AS step_id, wsr.pid AS pid
|
|
1030
|
+
FROM workflow_runs wr
|
|
1031
|
+
JOIN workflow_step_runs wsr ON wsr.workflow_run_id = wr.id
|
|
1032
|
+
WHERE wr.loop_run_id = ?
|
|
1033
|
+
AND wr.status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1034
|
+
AND wsr.status = 'running'
|
|
1035
|
+
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1036
|
+
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1037
|
+
}
|
|
927
1038
|
createSkippedRun(loop, scheduledFor, reason) {
|
|
928
1039
|
const now = nowIso();
|
|
929
1040
|
const run = {
|
|
@@ -971,6 +1082,14 @@ class Store {
|
|
|
971
1082
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
972
1083
|
if (existing) {
|
|
973
1084
|
if (existing.status === "running") {
|
|
1085
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && existing.pid && isProcessAlive(existing.pid)) {
|
|
1086
|
+
this.db.exec("COMMIT");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && this.hasLiveWorkflowStepProcesses(existing.id)) {
|
|
1090
|
+
this.db.exec("COMMIT");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
974
1093
|
const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
|
|
975
1094
|
claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
|
|
976
1095
|
duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
@@ -1093,8 +1212,26 @@ class Store {
|
|
|
1093
1212
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
1094
1213
|
const recovered = [];
|
|
1095
1214
|
for (const row of rows) {
|
|
1215
|
+
if (row.pid && isProcessAlive(row.pid))
|
|
1216
|
+
continue;
|
|
1217
|
+
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1218
|
+
continue;
|
|
1219
|
+
const finished = now.toISOString();
|
|
1096
1220
|
this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
1097
|
-
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished:
|
|
1221
|
+
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
|
|
1222
|
+
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);
|
|
1223
|
+
for (const workflowRow of workflowRows) {
|
|
1224
|
+
this.db.query(`UPDATE workflow_runs
|
|
1225
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1226
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
|
|
1227
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1228
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1229
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
|
|
1230
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1231
|
+
error: "parent loop run lease expired before completion",
|
|
1232
|
+
loopRunId: row.id
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1098
1235
|
const run = this.getRun(row.id);
|
|
1099
1236
|
if (run)
|
|
1100
1237
|
recovered.push(run);
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { ExecutorResult, Loop, LoopRun, WorkflowSpec } from "../types.js";
|
|
2
|
-
import { type ExecuteOptions } from "./executor.js";
|
|
2
|
+
import { preflightTarget, type ExecuteOptions } from "./executor.js";
|
|
3
3
|
import type { Store } from "./store.js";
|
|
4
4
|
export interface ExecuteWorkflowOptions extends ExecuteOptions {
|
|
5
5
|
loop?: Loop;
|
|
6
6
|
loopRun?: LoopRun;
|
|
7
7
|
scheduledFor?: string;
|
|
8
8
|
idempotencyKey?: string;
|
|
9
|
+
cancelPollMs?: number;
|
|
10
|
+
signalTimeoutMessage?: () => string | undefined;
|
|
9
11
|
}
|
|
10
12
|
export declare function executeWorkflow(store: Store, workflow: WorkflowSpec, opts?: ExecuteWorkflowOptions): Promise<ExecutorResult>;
|
|
13
|
+
export declare function preflightWorkflow(workflow: WorkflowSpec, opts?: ExecuteOptions): ReturnType<typeof preflightTarget>[];
|
|
11
14
|
export declare function executeLoopTarget(store: Store, loop: Loop, run: LoopRun, opts?: ExecuteOptions): Promise<ExecutorResult>;
|