@hasna/loops 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -2
- package/dist/cli/index.js +928 -33
- package/dist/daemon/index.js +779 -23
- package/dist/index.d.ts +3 -1
- package/dist/index.js +785 -22
- package/dist/lib/accounts.d.ts +4 -0
- package/dist/lib/executor.d.ts +12 -1
- package/dist/lib/format.d.ts +5 -1
- package/dist/lib/store.d.ts +37 -2
- package/dist/lib/store.js +489 -5
- package/dist/lib/workflow-runner.d.ts +11 -0
- package/dist/lib/workflow-spec.d.ts +5 -0
- package/dist/sdk/index.js +780 -22
- package/dist/types.d.ts +87 -2
- package/docs/USAGE.md +68 -3
- package/package.json +2 -2
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AccountRef, AgentProvider } from "../types.js";
|
|
2
|
+
export declare function accountToolForProvider(provider: AgentProvider): string;
|
|
3
|
+
export declare function parseAccountExportLines(output: string): Record<string, string>;
|
|
4
|
+
export declare function resolveAccountEnv(account: AccountRef | undefined, toolHint?: string, env?: NodeJS.ProcessEnv): Record<string, string>;
|
package/dist/lib/executor.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import type { ExecutorResult, Loop, LoopRun } from "../types.js";
|
|
1
|
+
import type { ExecutableTarget, ExecutorResult, Loop, LoopRun } from "../types.js";
|
|
2
2
|
export interface ExecuteOptions {
|
|
3
3
|
maxOutputBytes?: number;
|
|
4
4
|
env?: NodeJS.ProcessEnv;
|
|
5
5
|
log?: (message: string) => void;
|
|
6
6
|
}
|
|
7
|
+
export interface ExecutionMetadata {
|
|
8
|
+
loopId?: string;
|
|
9
|
+
loopName?: string;
|
|
10
|
+
runId?: string;
|
|
11
|
+
scheduledFor?: string;
|
|
12
|
+
workflowId?: string;
|
|
13
|
+
workflowName?: string;
|
|
14
|
+
workflowRunId?: string;
|
|
15
|
+
workflowStepId?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function executeTarget(target: ExecutableTarget, metadata?: ExecutionMetadata, opts?: ExecuteOptions): Promise<ExecutorResult>;
|
|
7
18
|
export declare function executeLoop(loop: Loop, run: LoopRun, opts?: ExecuteOptions): Promise<ExecutorResult>;
|
package/dist/lib/format.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type { Loop, LoopRun } from "../types.js";
|
|
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
3
|
export declare function publicLoop(loop: Loop): Record<string, unknown>;
|
|
4
4
|
export declare function publicRun(run: LoopRun, showOutput?: boolean): Record<string, unknown>;
|
|
5
|
+
export declare function publicWorkflow(workflow: WorkflowSpec): Record<string, unknown>;
|
|
6
|
+
export declare function publicWorkflowRun(run: WorkflowRun): Record<string, unknown>;
|
|
7
|
+
export declare function publicWorkflowStepRun(run: WorkflowStepRun, showOutput?: boolean): Record<string, unknown>;
|
|
8
|
+
export declare function publicWorkflowEvent(event: WorkflowEvent): Record<string, unknown>;
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CreateLoopInput, Loop, LoopRun, LoopStatus, RunStatus } from "../types.js";
|
|
1
|
+
import type { CreateLoopInput, CreateWorkflowInput, Loop, LoopRun, LoopStatus, RunStatus, WorkflowEvent, WorkflowRun, WorkflowRunStatus, WorkflowSpec, WorkflowStepRun } from "../types.js";
|
|
2
2
|
export interface DaemonLease {
|
|
3
3
|
id: string;
|
|
4
4
|
pid: number;
|
|
@@ -12,6 +12,13 @@ export interface ClaimRunResult {
|
|
|
12
12
|
run: LoopRun;
|
|
13
13
|
loop: Loop;
|
|
14
14
|
}
|
|
15
|
+
export interface CreateWorkflowRunInput {
|
|
16
|
+
workflow: WorkflowSpec;
|
|
17
|
+
loop?: Loop;
|
|
18
|
+
loopRun?: LoopRun;
|
|
19
|
+
scheduledFor?: string;
|
|
20
|
+
idempotencyKey?: string;
|
|
21
|
+
}
|
|
15
22
|
export declare class Store {
|
|
16
23
|
private db;
|
|
17
24
|
constructor(path?: string);
|
|
@@ -27,12 +34,40 @@ export declare class Store {
|
|
|
27
34
|
dueLoops(now: Date): Loop[];
|
|
28
35
|
updateLoop(id: string, patch: Partial<Pick<Loop, "status" | "nextRunAt" | "retryScheduledFor" | "expiresAt">>): Loop;
|
|
29
36
|
deleteLoop(idOrName: string): boolean;
|
|
37
|
+
createWorkflow(input: CreateWorkflowInput): WorkflowSpec;
|
|
38
|
+
getWorkflow(id: string): WorkflowSpec | undefined;
|
|
39
|
+
findWorkflowByName(name: string): WorkflowSpec | undefined;
|
|
40
|
+
requireWorkflow(idOrName: string): WorkflowSpec;
|
|
41
|
+
listWorkflows(opts?: {
|
|
42
|
+
status?: WorkflowSpec["status"];
|
|
43
|
+
limit?: number;
|
|
44
|
+
}): WorkflowSpec[];
|
|
45
|
+
archiveWorkflow(idOrName: string): WorkflowSpec;
|
|
46
|
+
createWorkflowRun(input: CreateWorkflowRunInput): WorkflowRun;
|
|
47
|
+
getWorkflowRun(id: string): WorkflowRun | undefined;
|
|
48
|
+
listWorkflowRuns(opts?: {
|
|
49
|
+
workflowId?: string;
|
|
50
|
+
loopRunId?: string;
|
|
51
|
+
limit?: number;
|
|
52
|
+
}): WorkflowRun[];
|
|
53
|
+
listWorkflowStepRuns(workflowRunId: string): WorkflowStepRun[];
|
|
54
|
+
getWorkflowStepRun(workflowRunId: string, stepId: string): WorkflowStepRun | undefined;
|
|
55
|
+
startWorkflowStepRun(workflowRunId: string, stepId: string): WorkflowStepRun;
|
|
56
|
+
finalizeWorkflowStepRun(workflowRunId: string, stepId: string, patch: Pick<WorkflowStepRun, "status" | "finishedAt" | "durationMs" | "stdout" | "stderr"> & Partial<Pick<WorkflowStepRun, "exitCode" | "error">>): WorkflowStepRun;
|
|
57
|
+
skipWorkflowStepRun(workflowRunId: string, stepId: string, reason: string): WorkflowStepRun;
|
|
58
|
+
finalizeWorkflowRun(workflowRunId: string, status: WorkflowRunStatus, patch?: Partial<Pick<WorkflowRun, "finishedAt" | "durationMs" | "error">>): WorkflowRun;
|
|
59
|
+
appendWorkflowEvent(workflowRunId: string, eventType: string, stepId?: string, payload?: Record<string, unknown>): WorkflowEvent;
|
|
60
|
+
listWorkflowEvents(workflowRunId: string, limit?: number): WorkflowEvent[];
|
|
30
61
|
hasRunningRun(loopId: string): boolean;
|
|
31
62
|
createSkippedRun(loop: Loop, scheduledFor: string, reason: string): LoopRun;
|
|
32
63
|
getRun(id: string): LoopRun | undefined;
|
|
33
64
|
getRunBySlot(loopId: string, scheduledFor: string): LoopRun | undefined;
|
|
34
65
|
claimRun(loop: Loop, scheduledFor: string, runnerId: string, now?: Date): ClaimRunResult | undefined;
|
|
35
|
-
finalizeRun(id: string, patch: Pick<LoopRun, "status" | "finishedAt" | "durationMs" | "stdout" | "stderr"> & Partial<Pick<LoopRun, "exitCode" | "error" | "pid"
|
|
66
|
+
finalizeRun(id: string, patch: Pick<LoopRun, "status" | "finishedAt" | "durationMs" | "stdout" | "stderr"> & Partial<Pick<LoopRun, "exitCode" | "error" | "pid">>, opts?: {
|
|
67
|
+
claimedBy?: string;
|
|
68
|
+
now?: Date;
|
|
69
|
+
}): LoopRun;
|
|
70
|
+
heartbeatRunLease(id: string, claimedBy: string, leaseMs: number, now?: Date): LoopRun | undefined;
|
|
36
71
|
listRuns(opts?: {
|
|
37
72
|
loopId?: string;
|
|
38
73
|
status?: RunStatus;
|
package/dist/lib/store.js
CHANGED
|
@@ -234,6 +234,100 @@ function parseDuration(input) {
|
|
|
234
234
|
return Math.round(value * multiplier);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// src/lib/workflow-spec.ts
|
|
238
|
+
function assertObject(value, label) {
|
|
239
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
240
|
+
throw new Error(`${label} must be an object`);
|
|
241
|
+
}
|
|
242
|
+
function assertString(value, label) {
|
|
243
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
244
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
245
|
+
}
|
|
246
|
+
function validateTarget(value, label) {
|
|
247
|
+
assertObject(value, label);
|
|
248
|
+
if (value.type === "command") {
|
|
249
|
+
assertString(value.command, `${label}.command`);
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
if (value.type === "agent") {
|
|
253
|
+
assertString(value.provider, `${label}.provider`);
|
|
254
|
+
assertString(value.prompt, `${label}.prompt`);
|
|
255
|
+
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
256
|
+
if (!providers.includes(value.provider))
|
|
257
|
+
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`${label}.type must be command or agent`);
|
|
261
|
+
}
|
|
262
|
+
function normalizeCreateWorkflowInput(input) {
|
|
263
|
+
assertString(input.name, "workflow.name");
|
|
264
|
+
if (!Array.isArray(input.steps) || input.steps.length === 0)
|
|
265
|
+
throw new Error("workflow.steps must contain at least one step");
|
|
266
|
+
const seen = new Set;
|
|
267
|
+
const steps = input.steps.map((step, index) => {
|
|
268
|
+
assertObject(step, `workflow.steps[${index}]`);
|
|
269
|
+
assertString(step.id, `workflow.steps[${index}].id`);
|
|
270
|
+
if (seen.has(step.id))
|
|
271
|
+
throw new Error(`duplicate workflow step id: ${step.id}`);
|
|
272
|
+
seen.add(step.id);
|
|
273
|
+
return {
|
|
274
|
+
...step,
|
|
275
|
+
id: step.id,
|
|
276
|
+
target: validateTarget(step.target, `workflow.steps[${index}].target`),
|
|
277
|
+
dependsOn: step.dependsOn ?? [],
|
|
278
|
+
continueOnFailure: step.continueOnFailure ?? false
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
for (const step of steps) {
|
|
282
|
+
for (const dependency of step.dependsOn ?? []) {
|
|
283
|
+
if (!seen.has(dependency))
|
|
284
|
+
throw new Error(`step ${step.id} depends on missing step ${dependency}`);
|
|
285
|
+
if (dependency === step.id)
|
|
286
|
+
throw new Error(`step ${step.id} cannot depend on itself`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
workflowExecutionOrder({ steps });
|
|
290
|
+
return { ...input, name: input.name.trim(), version: input.version ?? 1, steps };
|
|
291
|
+
}
|
|
292
|
+
function workflowExecutionOrder(workflow) {
|
|
293
|
+
const byId = new Map(workflow.steps.map((step) => [step.id, step]));
|
|
294
|
+
const visiting = new Set;
|
|
295
|
+
const visited = new Set;
|
|
296
|
+
const order = [];
|
|
297
|
+
function visit(step) {
|
|
298
|
+
if (visited.has(step.id))
|
|
299
|
+
return;
|
|
300
|
+
if (visiting.has(step.id))
|
|
301
|
+
throw new Error(`workflow dependency cycle includes step ${step.id}`);
|
|
302
|
+
visiting.add(step.id);
|
|
303
|
+
for (const dependencyId of step.dependsOn ?? []) {
|
|
304
|
+
const dependency = byId.get(dependencyId);
|
|
305
|
+
if (!dependency)
|
|
306
|
+
throw new Error(`step ${step.id} depends on missing step ${dependencyId}`);
|
|
307
|
+
visit(dependency);
|
|
308
|
+
}
|
|
309
|
+
visiting.delete(step.id);
|
|
310
|
+
visited.add(step.id);
|
|
311
|
+
order.push(step);
|
|
312
|
+
}
|
|
313
|
+
for (const step of workflow.steps)
|
|
314
|
+
visit(step);
|
|
315
|
+
return order;
|
|
316
|
+
}
|
|
317
|
+
function workflowBodyFromJson(value, fallbackName) {
|
|
318
|
+
assertObject(value, "workflow file");
|
|
319
|
+
const rawName = fallbackName ?? value.name;
|
|
320
|
+
assertString(rawName, "workflow.name");
|
|
321
|
+
if (!Array.isArray(value.steps))
|
|
322
|
+
throw new Error("workflow.steps must be an array");
|
|
323
|
+
return normalizeCreateWorkflowInput({
|
|
324
|
+
name: rawName,
|
|
325
|
+
description: typeof value.description === "string" ? value.description : undefined,
|
|
326
|
+
version: typeof value.version === "number" ? value.version : undefined,
|
|
327
|
+
steps: value.steps
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
237
331
|
// src/lib/store.ts
|
|
238
332
|
function rowToLoop(row) {
|
|
239
333
|
return {
|
|
@@ -278,6 +372,67 @@ function rowToRun(row) {
|
|
|
278
372
|
updatedAt: row.updated_at
|
|
279
373
|
};
|
|
280
374
|
}
|
|
375
|
+
function rowToWorkflow(row) {
|
|
376
|
+
return {
|
|
377
|
+
id: row.id,
|
|
378
|
+
name: row.name,
|
|
379
|
+
description: row.description ?? undefined,
|
|
380
|
+
version: row.version,
|
|
381
|
+
status: row.status,
|
|
382
|
+
steps: JSON.parse(row.steps_json),
|
|
383
|
+
createdAt: row.created_at,
|
|
384
|
+
updatedAt: row.updated_at
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function rowToWorkflowRun(row) {
|
|
388
|
+
return {
|
|
389
|
+
id: row.id,
|
|
390
|
+
workflowId: row.workflow_id,
|
|
391
|
+
workflowName: row.workflow_name,
|
|
392
|
+
loopId: row.loop_id ?? undefined,
|
|
393
|
+
loopRunId: row.loop_run_id ?? undefined,
|
|
394
|
+
scheduledFor: row.scheduled_for ?? undefined,
|
|
395
|
+
idempotencyKey: row.idempotency_key ?? undefined,
|
|
396
|
+
status: row.status,
|
|
397
|
+
startedAt: row.started_at ?? undefined,
|
|
398
|
+
finishedAt: row.finished_at ?? undefined,
|
|
399
|
+
durationMs: row.duration_ms ?? undefined,
|
|
400
|
+
error: row.error ?? undefined,
|
|
401
|
+
createdAt: row.created_at,
|
|
402
|
+
updatedAt: row.updated_at
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function rowToWorkflowStepRun(row) {
|
|
406
|
+
return {
|
|
407
|
+
id: row.id,
|
|
408
|
+
workflowRunId: row.workflow_run_id,
|
|
409
|
+
stepId: row.step_id,
|
|
410
|
+
sequence: row.sequence,
|
|
411
|
+
status: row.status,
|
|
412
|
+
startedAt: row.started_at ?? undefined,
|
|
413
|
+
finishedAt: row.finished_at ?? undefined,
|
|
414
|
+
exitCode: row.exit_code ?? undefined,
|
|
415
|
+
durationMs: row.duration_ms ?? undefined,
|
|
416
|
+
stdout: row.stdout ?? undefined,
|
|
417
|
+
stderr: row.stderr ?? undefined,
|
|
418
|
+
error: row.error ?? undefined,
|
|
419
|
+
accountProfile: row.account_profile ?? undefined,
|
|
420
|
+
accountTool: row.account_tool ?? undefined,
|
|
421
|
+
createdAt: row.created_at,
|
|
422
|
+
updatedAt: row.updated_at
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function rowToWorkflowEvent(row) {
|
|
426
|
+
return {
|
|
427
|
+
id: row.id,
|
|
428
|
+
workflowRunId: row.workflow_run_id,
|
|
429
|
+
sequence: row.sequence,
|
|
430
|
+
eventType: row.event_type,
|
|
431
|
+
stepId: row.step_id ?? undefined,
|
|
432
|
+
payload: row.payload_json ? JSON.parse(row.payload_json) : undefined,
|
|
433
|
+
createdAt: row.created_at
|
|
434
|
+
};
|
|
435
|
+
}
|
|
281
436
|
function rowToLease(row) {
|
|
282
437
|
return {
|
|
283
438
|
id: row.id,
|
|
@@ -303,6 +458,11 @@ class Store {
|
|
|
303
458
|
}
|
|
304
459
|
migrate() {
|
|
305
460
|
this.db.exec(`
|
|
461
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
462
|
+
id TEXT PRIMARY KEY,
|
|
463
|
+
applied_at TEXT NOT NULL
|
|
464
|
+
);
|
|
465
|
+
|
|
306
466
|
CREATE TABLE IF NOT EXISTS loops (
|
|
307
467
|
id TEXT PRIMARY KEY,
|
|
308
468
|
name TEXT NOT NULL,
|
|
@@ -359,7 +519,78 @@ class Store {
|
|
|
359
519
|
created_at TEXT NOT NULL,
|
|
360
520
|
updated_at TEXT NOT NULL
|
|
361
521
|
);
|
|
522
|
+
|
|
523
|
+
CREATE TABLE IF NOT EXISTS workflow_specs (
|
|
524
|
+
id TEXT PRIMARY KEY,
|
|
525
|
+
name TEXT NOT NULL,
|
|
526
|
+
description TEXT,
|
|
527
|
+
version INTEGER NOT NULL,
|
|
528
|
+
status TEXT NOT NULL,
|
|
529
|
+
steps_json TEXT NOT NULL,
|
|
530
|
+
created_at TEXT NOT NULL,
|
|
531
|
+
updated_at TEXT NOT NULL
|
|
532
|
+
);
|
|
533
|
+
CREATE INDEX IF NOT EXISTS idx_workflows_status_name ON workflow_specs(status, name);
|
|
534
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_name_active ON workflow_specs(name) WHERE status = 'active';
|
|
535
|
+
|
|
536
|
+
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
537
|
+
id TEXT PRIMARY KEY,
|
|
538
|
+
workflow_id TEXT NOT NULL REFERENCES workflow_specs(id) ON DELETE CASCADE,
|
|
539
|
+
workflow_name TEXT NOT NULL,
|
|
540
|
+
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
541
|
+
loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
|
|
542
|
+
scheduled_for TEXT,
|
|
543
|
+
idempotency_key TEXT,
|
|
544
|
+
status TEXT NOT NULL,
|
|
545
|
+
started_at TEXT,
|
|
546
|
+
finished_at TEXT,
|
|
547
|
+
duration_ms INTEGER,
|
|
548
|
+
error TEXT,
|
|
549
|
+
created_at TEXT NOT NULL,
|
|
550
|
+
updated_at TEXT NOT NULL
|
|
551
|
+
);
|
|
552
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_idempotency
|
|
553
|
+
ON workflow_runs(workflow_id, idempotency_key)
|
|
554
|
+
WHERE idempotency_key IS NOT NULL;
|
|
555
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
|
|
556
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
|
|
557
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
558
|
+
|
|
559
|
+
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
560
|
+
id TEXT PRIMARY KEY,
|
|
561
|
+
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
562
|
+
step_id TEXT NOT NULL,
|
|
563
|
+
sequence INTEGER NOT NULL,
|
|
564
|
+
status TEXT NOT NULL,
|
|
565
|
+
started_at TEXT,
|
|
566
|
+
finished_at TEXT,
|
|
567
|
+
exit_code INTEGER,
|
|
568
|
+
duration_ms INTEGER,
|
|
569
|
+
stdout TEXT,
|
|
570
|
+
stderr TEXT,
|
|
571
|
+
error TEXT,
|
|
572
|
+
account_profile TEXT,
|
|
573
|
+
account_tool TEXT,
|
|
574
|
+
created_at TEXT NOT NULL,
|
|
575
|
+
updated_at TEXT NOT NULL,
|
|
576
|
+
UNIQUE(workflow_run_id, step_id)
|
|
577
|
+
);
|
|
578
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_run_sequence ON workflow_step_runs(workflow_run_id, sequence);
|
|
579
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_status ON workflow_step_runs(status);
|
|
580
|
+
|
|
581
|
+
CREATE TABLE IF NOT EXISTS workflow_events (
|
|
582
|
+
id TEXT PRIMARY KEY,
|
|
583
|
+
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
584
|
+
sequence INTEGER NOT NULL,
|
|
585
|
+
event_type TEXT NOT NULL,
|
|
586
|
+
step_id TEXT,
|
|
587
|
+
payload_json TEXT,
|
|
588
|
+
created_at TEXT NOT NULL,
|
|
589
|
+
UNIQUE(workflow_run_id, sequence)
|
|
590
|
+
);
|
|
591
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
362
592
|
`);
|
|
593
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
363
594
|
}
|
|
364
595
|
createLoop(input, from = new Date) {
|
|
365
596
|
const now = nowIso();
|
|
@@ -451,6 +682,244 @@ class Store {
|
|
|
451
682
|
const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
|
|
452
683
|
return res.changes > 0;
|
|
453
684
|
}
|
|
685
|
+
createWorkflow(input) {
|
|
686
|
+
const normalized = normalizeCreateWorkflowInput(input);
|
|
687
|
+
const now = nowIso();
|
|
688
|
+
const workflow = {
|
|
689
|
+
id: genId(),
|
|
690
|
+
name: normalized.name,
|
|
691
|
+
description: normalized.description,
|
|
692
|
+
version: normalized.version ?? 1,
|
|
693
|
+
status: "active",
|
|
694
|
+
steps: normalized.steps,
|
|
695
|
+
createdAt: now,
|
|
696
|
+
updatedAt: now
|
|
697
|
+
};
|
|
698
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, steps_json, created_at, updated_at)
|
|
699
|
+
VALUES ($id, $name, $description, $version, $status, $steps, $created, $updated)`).run({
|
|
700
|
+
$id: workflow.id,
|
|
701
|
+
$name: workflow.name,
|
|
702
|
+
$description: workflow.description ?? null,
|
|
703
|
+
$version: workflow.version,
|
|
704
|
+
$status: workflow.status,
|
|
705
|
+
$steps: JSON.stringify(workflow.steps),
|
|
706
|
+
$created: workflow.createdAt,
|
|
707
|
+
$updated: workflow.updatedAt
|
|
708
|
+
});
|
|
709
|
+
return workflow;
|
|
710
|
+
}
|
|
711
|
+
getWorkflow(id) {
|
|
712
|
+
const row = this.db.query("SELECT * FROM workflow_specs WHERE id = ?").get(id);
|
|
713
|
+
return row ? rowToWorkflow(row) : undefined;
|
|
714
|
+
}
|
|
715
|
+
findWorkflowByName(name) {
|
|
716
|
+
const row = this.db.query("SELECT * FROM workflow_specs WHERE name = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 1").get(name);
|
|
717
|
+
return row ? rowToWorkflow(row) : undefined;
|
|
718
|
+
}
|
|
719
|
+
requireWorkflow(idOrName) {
|
|
720
|
+
return this.getWorkflow(idOrName) ?? this.findWorkflowByName(idOrName) ?? (() => {
|
|
721
|
+
throw new Error(`workflow not found: ${idOrName}`);
|
|
722
|
+
})();
|
|
723
|
+
}
|
|
724
|
+
listWorkflows(opts = {}) {
|
|
725
|
+
const limit = opts.limit ?? 200;
|
|
726
|
+
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);
|
|
727
|
+
return rows.map(rowToWorkflow);
|
|
728
|
+
}
|
|
729
|
+
archiveWorkflow(idOrName) {
|
|
730
|
+
const workflow = this.requireWorkflow(idOrName);
|
|
731
|
+
const updated = nowIso();
|
|
732
|
+
this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=?").run(updated, workflow.id);
|
|
733
|
+
const archived = this.getWorkflow(workflow.id);
|
|
734
|
+
if (!archived)
|
|
735
|
+
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
736
|
+
return archived;
|
|
737
|
+
}
|
|
738
|
+
createWorkflowRun(input) {
|
|
739
|
+
const now = nowIso();
|
|
740
|
+
if (input.idempotencyKey) {
|
|
741
|
+
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
742
|
+
if (existing)
|
|
743
|
+
return rowToWorkflowRun(existing);
|
|
744
|
+
}
|
|
745
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
746
|
+
try {
|
|
747
|
+
if (input.idempotencyKey) {
|
|
748
|
+
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
749
|
+
if (existing) {
|
|
750
|
+
this.db.exec("COMMIT");
|
|
751
|
+
return rowToWorkflowRun(existing);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const runId = genId();
|
|
755
|
+
this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
|
|
756
|
+
status, started_at, finished_at, duration_ms, error, created_at, updated_at)
|
|
757
|
+
VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
|
|
758
|
+
'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
|
|
759
|
+
$id: runId,
|
|
760
|
+
$workflowId: input.workflow.id,
|
|
761
|
+
$workflowName: input.workflow.name,
|
|
762
|
+
$loopId: input.loop?.id ?? null,
|
|
763
|
+
$loopRunId: input.loopRun?.id ?? null,
|
|
764
|
+
$scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
|
|
765
|
+
$idempotencyKey: input.idempotencyKey ?? null,
|
|
766
|
+
$started: now,
|
|
767
|
+
$created: now,
|
|
768
|
+
$updated: now
|
|
769
|
+
});
|
|
770
|
+
input.workflow.steps.forEach((step, sequence) => {
|
|
771
|
+
const account = step.account ?? step.target.account;
|
|
772
|
+
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,
|
|
775
|
+
$accountProfile, $accountTool, $created, $updated)`).run({
|
|
776
|
+
$id: genId(),
|
|
777
|
+
$workflowRunId: runId,
|
|
778
|
+
$stepId: step.id,
|
|
779
|
+
$sequence: sequence,
|
|
780
|
+
$accountProfile: account?.profile ?? null,
|
|
781
|
+
$accountTool: account?.tool ?? null,
|
|
782
|
+
$created: now,
|
|
783
|
+
$updated: now
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
|
|
787
|
+
VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
|
|
788
|
+
$id: genId(),
|
|
789
|
+
$workflowRunId: runId,
|
|
790
|
+
$payload: JSON.stringify({
|
|
791
|
+
workflowId: input.workflow.id,
|
|
792
|
+
workflowName: input.workflow.name,
|
|
793
|
+
stepCount: input.workflow.steps.length,
|
|
794
|
+
loopId: input.loop?.id,
|
|
795
|
+
loopRunId: input.loopRun?.id
|
|
796
|
+
}),
|
|
797
|
+
$created: now
|
|
798
|
+
});
|
|
799
|
+
this.db.exec("COMMIT");
|
|
800
|
+
const run = this.getWorkflowRun(runId);
|
|
801
|
+
if (!run)
|
|
802
|
+
throw new Error(`workflow run not found after create: ${runId}`);
|
|
803
|
+
return run;
|
|
804
|
+
} catch (error) {
|
|
805
|
+
try {
|
|
806
|
+
this.db.exec("ROLLBACK");
|
|
807
|
+
} catch {}
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
getWorkflowRun(id) {
|
|
812
|
+
const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
|
|
813
|
+
return row ? rowToWorkflowRun(row) : undefined;
|
|
814
|
+
}
|
|
815
|
+
listWorkflowRuns(opts = {}) {
|
|
816
|
+
const limit = opts.limit ?? 100;
|
|
817
|
+
let rows;
|
|
818
|
+
if (opts.workflowId) {
|
|
819
|
+
rows = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.workflowId, limit);
|
|
820
|
+
} else if (opts.loopRunId) {
|
|
821
|
+
rows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopRunId, limit);
|
|
822
|
+
} else {
|
|
823
|
+
rows = this.db.query("SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
824
|
+
}
|
|
825
|
+
return rows.map(rowToWorkflowRun);
|
|
826
|
+
}
|
|
827
|
+
listWorkflowStepRuns(workflowRunId) {
|
|
828
|
+
const rows = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? ORDER BY sequence ASC").all(workflowRunId);
|
|
829
|
+
return rows.map(rowToWorkflowStepRun);
|
|
830
|
+
}
|
|
831
|
+
getWorkflowStepRun(workflowRunId, stepId) {
|
|
832
|
+
const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
|
|
833
|
+
return row ? rowToWorkflowStepRun(row) : undefined;
|
|
834
|
+
}
|
|
835
|
+
startWorkflowStepRun(workflowRunId, stepId) {
|
|
836
|
+
const now = nowIso();
|
|
837
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
838
|
+
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 AND step_id=$stepId AND status IN ('pending', 'running', 'failed', 'timed_out')`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
|
|
841
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
842
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
843
|
+
if (!run)
|
|
844
|
+
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
845
|
+
return run;
|
|
846
|
+
}
|
|
847
|
+
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
848
|
+
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({
|
|
852
|
+
$workflowRunId: workflowRunId,
|
|
853
|
+
$stepId: stepId,
|
|
854
|
+
$status: patch.status,
|
|
855
|
+
$finished: finishedAt,
|
|
856
|
+
$exitCode: patch.exitCode ?? null,
|
|
857
|
+
$durationMs: patch.durationMs ?? null,
|
|
858
|
+
$stdout: patch.stdout ?? null,
|
|
859
|
+
$stderr: patch.stderr ?? null,
|
|
860
|
+
$error: patch.error ?? null,
|
|
861
|
+
$updated: finishedAt
|
|
862
|
+
});
|
|
863
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
864
|
+
exitCode: patch.exitCode,
|
|
865
|
+
error: patch.error
|
|
866
|
+
});
|
|
867
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
868
|
+
if (!run)
|
|
869
|
+
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
870
|
+
return run;
|
|
871
|
+
}
|
|
872
|
+
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
873
|
+
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
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
877
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
878
|
+
if (!run)
|
|
879
|
+
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
880
|
+
return run;
|
|
881
|
+
}
|
|
882
|
+
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
883
|
+
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({
|
|
886
|
+
$id: workflowRunId,
|
|
887
|
+
$status: status,
|
|
888
|
+
$finished: finishedAt,
|
|
889
|
+
$durationMs: patch.durationMs ?? null,
|
|
890
|
+
$error: patch.error ?? null,
|
|
891
|
+
$updated: finishedAt
|
|
892
|
+
});
|
|
893
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
894
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
895
|
+
if (!run)
|
|
896
|
+
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
897
|
+
return run;
|
|
898
|
+
}
|
|
899
|
+
appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
|
|
900
|
+
const now = nowIso();
|
|
901
|
+
const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
|
|
902
|
+
const sequence = (current?.sequence ?? 0) + 1;
|
|
903
|
+
const id = genId();
|
|
904
|
+
this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
|
|
905
|
+
VALUES ($id, $workflowRunId, $sequence, $eventType, $stepId, $payload, $created)`).run({
|
|
906
|
+
$id: id,
|
|
907
|
+
$workflowRunId: workflowRunId,
|
|
908
|
+
$sequence: sequence,
|
|
909
|
+
$eventType: eventType,
|
|
910
|
+
$stepId: stepId ?? null,
|
|
911
|
+
$payload: payload ? JSON.stringify(payload) : null,
|
|
912
|
+
$created: now
|
|
913
|
+
});
|
|
914
|
+
const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
|
|
915
|
+
if (!event)
|
|
916
|
+
throw new Error(`workflow event not found after append: ${id}`);
|
|
917
|
+
return rowToWorkflowEvent(event);
|
|
918
|
+
}
|
|
919
|
+
listWorkflowEvents(workflowRunId, limit = 200) {
|
|
920
|
+
const rows = this.db.query("SELECT * FROM workflow_events WHERE workflow_run_id = ? ORDER BY sequence ASC LIMIT ?").all(workflowRunId, limit);
|
|
921
|
+
return rows.map(rowToWorkflowEvent);
|
|
922
|
+
}
|
|
454
923
|
hasRunningRun(loopId) {
|
|
455
924
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
456
925
|
return (row?.count ?? 0) > 0;
|
|
@@ -571,10 +1040,9 @@ class Store {
|
|
|
571
1040
|
throw error;
|
|
572
1041
|
}
|
|
573
1042
|
}
|
|
574
|
-
finalizeRun(id, patch) {
|
|
1043
|
+
finalizeRun(id, patch, opts = {}) {
|
|
575
1044
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
576
|
-
|
|
577
|
-
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run({
|
|
1045
|
+
const params = {
|
|
578
1046
|
$id: id,
|
|
579
1047
|
$status: patch.status,
|
|
580
1048
|
$finished: finishedAt,
|
|
@@ -584,13 +1052,29 @@ class Store {
|
|
|
584
1052
|
$stdout: patch.stdout ?? null,
|
|
585
1053
|
$stderr: patch.stderr ?? null,
|
|
586
1054
|
$error: patch.error ?? null,
|
|
587
|
-
$updated: finishedAt
|
|
588
|
-
|
|
1055
|
+
$updated: finishedAt,
|
|
1056
|
+
$claimedBy: opts.claimedBy ?? null,
|
|
1057
|
+
$now: (opts.now ?? new Date).toISOString()
|
|
1058
|
+
};
|
|
1059
|
+
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,
|
|
1060
|
+
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
1061
|
+
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,
|
|
1062
|
+
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
589
1063
|
const run = this.getRun(id);
|
|
590
1064
|
if (!run)
|
|
591
1065
|
throw new Error(`run not found after finalize: ${id}`);
|
|
1066
|
+
if (opts.claimedBy && res.changes !== 1)
|
|
1067
|
+
return run;
|
|
592
1068
|
return run;
|
|
593
1069
|
}
|
|
1070
|
+
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
|
|
1071
|
+
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1072
|
+
const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
|
|
1073
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $claimedBy: claimedBy, $expires: expiresAt, $updated: now.toISOString() });
|
|
1074
|
+
if (res.changes !== 1)
|
|
1075
|
+
return;
|
|
1076
|
+
return this.getRun(id);
|
|
1077
|
+
}
|
|
594
1078
|
listRuns(opts = {}) {
|
|
595
1079
|
const limit = opts.limit ?? 100;
|
|
596
1080
|
let rows;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExecutorResult, Loop, LoopRun, WorkflowSpec } from "../types.js";
|
|
2
|
+
import { type ExecuteOptions } from "./executor.js";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
export interface ExecuteWorkflowOptions extends ExecuteOptions {
|
|
5
|
+
loop?: Loop;
|
|
6
|
+
loopRun?: LoopRun;
|
|
7
|
+
scheduledFor?: string;
|
|
8
|
+
idempotencyKey?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function executeWorkflow(store: Store, workflow: WorkflowSpec, opts?: ExecuteWorkflowOptions): Promise<ExecutorResult>;
|
|
11
|
+
export declare function executeLoopTarget(store: Store, loop: Loop, run: LoopRun, opts?: ExecuteOptions): Promise<ExecutorResult>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CreateWorkflowInput, WorkflowSpec, WorkflowStep } from "../types.js";
|
|
2
|
+
export type WorkflowSpecBody = Pick<WorkflowSpec, "name" | "description" | "version" | "steps">;
|
|
3
|
+
export declare function normalizeCreateWorkflowInput(input: CreateWorkflowInput): CreateWorkflowInput;
|
|
4
|
+
export declare function workflowExecutionOrder(workflow: Pick<WorkflowSpec, "steps">): WorkflowStep[];
|
|
5
|
+
export declare function workflowBodyFromJson(value: unknown, fallbackName?: string): CreateWorkflowInput;
|