@hasna/loops 0.3.54 → 0.3.55
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 +32 -4
- package/dist/cli/index.js +357 -65
- package/dist/daemon/index.js +165 -18
- package/dist/index.js +226 -55
- package/dist/lib/store.d.ts +16 -1
- package/dist/lib/store.js +147 -3
- package/dist/lib/templates.d.ts +4 -2
- package/dist/sdk/index.js +164 -17
- package/dist/types.d.ts +5 -4
- package/docs/USAGE.md +32 -4
- package/docs/workflows/transcript-feedback-to-loops.json +2 -2
- package/package.json +1 -1
package/dist/lib/store.js
CHANGED
|
@@ -329,6 +329,15 @@ function optionalPositiveInteger(value, label) {
|
|
|
329
329
|
throw new Error(`${label} must be a positive integer`);
|
|
330
330
|
return value;
|
|
331
331
|
}
|
|
332
|
+
function optionalTimeoutMs(value, label) {
|
|
333
|
+
if (value === undefined)
|
|
334
|
+
return;
|
|
335
|
+
if (value === null)
|
|
336
|
+
return null;
|
|
337
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
338
|
+
throw new Error(`${label} must be a positive integer or null for unlimited`);
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
332
341
|
function optionalBoolean(value, label) {
|
|
333
342
|
if (value === undefined)
|
|
334
343
|
return;
|
|
@@ -417,7 +426,7 @@ function validateTarget(value, label, opts) {
|
|
|
417
426
|
assertObject(value, label);
|
|
418
427
|
if (value.type === "command") {
|
|
419
428
|
assertString(value.command, `${label}.command`);
|
|
420
|
-
|
|
429
|
+
optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
|
|
421
430
|
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
422
431
|
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
423
432
|
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
@@ -436,7 +445,7 @@ function validateTarget(value, label, opts) {
|
|
|
436
445
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
437
446
|
if (!providers.includes(value.provider))
|
|
438
447
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
439
|
-
|
|
448
|
+
optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
|
|
440
449
|
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
441
450
|
if (value.authProfile !== undefined) {
|
|
442
451
|
assertString(value.authProfile, `${label}.authProfile`);
|
|
@@ -559,7 +568,7 @@ function normalizeCreateWorkflowInput(input, opts = {}) {
|
|
|
559
568
|
target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
|
|
560
569
|
dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
|
|
561
570
|
continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
|
|
562
|
-
timeoutMs:
|
|
571
|
+
timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
|
|
563
572
|
account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
|
|
564
573
|
};
|
|
565
574
|
});
|
|
@@ -1305,6 +1314,17 @@ class Store {
|
|
|
1305
1314
|
const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
|
|
1306
1315
|
return row ? rowToLoop(row) : undefined;
|
|
1307
1316
|
}
|
|
1317
|
+
requireUniqueLoop(idOrName) {
|
|
1318
|
+
const byId = this.getLoop(idOrName);
|
|
1319
|
+
if (byId)
|
|
1320
|
+
return byId;
|
|
1321
|
+
const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
|
|
1322
|
+
if (rows.length === 0)
|
|
1323
|
+
throw new Error(`loop not found: ${idOrName}`);
|
|
1324
|
+
if (rows.length > 1)
|
|
1325
|
+
throw new Error(`ambiguous loop name: ${idOrName}; use a loop id`);
|
|
1326
|
+
return rowToLoop(rows[0]);
|
|
1327
|
+
}
|
|
1308
1328
|
requireLoop(idOrName) {
|
|
1309
1329
|
return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
|
|
1310
1330
|
throw new Error(`loop not found: ${idOrName}`);
|
|
@@ -1379,6 +1399,130 @@ class Store {
|
|
|
1379
1399
|
throw new Error(`loop not found after update: ${id}`);
|
|
1380
1400
|
return after;
|
|
1381
1401
|
}
|
|
1402
|
+
activeLoopReferenceCount(workflowId) {
|
|
1403
|
+
const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
|
|
1404
|
+
let count = 0;
|
|
1405
|
+
for (const row of rows) {
|
|
1406
|
+
try {
|
|
1407
|
+
const target = JSON.parse(row.target_json);
|
|
1408
|
+
if (target.type === "workflow" && target.workflowId === workflowId)
|
|
1409
|
+
count += 1;
|
|
1410
|
+
} catch {}
|
|
1411
|
+
}
|
|
1412
|
+
return count;
|
|
1413
|
+
}
|
|
1414
|
+
archiveWorkflowIfUnreferenced(workflowId, updated) {
|
|
1415
|
+
if (this.activeLoopReferenceCount(workflowId) > 0)
|
|
1416
|
+
return;
|
|
1417
|
+
const workflow = this.getWorkflow(workflowId);
|
|
1418
|
+
if (!workflow || workflow.status !== "active")
|
|
1419
|
+
return;
|
|
1420
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
|
|
1421
|
+
if (res.changes !== 1)
|
|
1422
|
+
return;
|
|
1423
|
+
return this.getWorkflow(workflowId);
|
|
1424
|
+
}
|
|
1425
|
+
retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
|
|
1426
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
1427
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1428
|
+
try {
|
|
1429
|
+
const current = this.requireLoop(idOrName);
|
|
1430
|
+
if (current.target.type !== "workflow")
|
|
1431
|
+
throw new Error(`loop is not a workflow loop: ${idOrName}`);
|
|
1432
|
+
if (this.hasRunningRun(current.id))
|
|
1433
|
+
throw new Error(`refusing to retarget running loop: ${current.id}`);
|
|
1434
|
+
const workflow = this.requireWorkflow(workflowId);
|
|
1435
|
+
const target = { ...current.target, workflowId: workflow.id };
|
|
1436
|
+
if (opts.workflowTimeoutMs !== undefined)
|
|
1437
|
+
target.timeoutMs = opts.workflowTimeoutMs;
|
|
1438
|
+
const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
|
|
1439
|
+
WHERE id=$id
|
|
1440
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1441
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1442
|
+
))`).run({
|
|
1443
|
+
$id: current.id,
|
|
1444
|
+
$target: JSON.stringify(target),
|
|
1445
|
+
$updated: updated,
|
|
1446
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1447
|
+
$now: updated
|
|
1448
|
+
});
|
|
1449
|
+
if (res.changes !== 1)
|
|
1450
|
+
throw new Error("daemon lease lost");
|
|
1451
|
+
this.db.exec("COMMIT");
|
|
1452
|
+
const after = this.getLoop(current.id);
|
|
1453
|
+
if (!after)
|
|
1454
|
+
throw new Error(`loop not found after retarget: ${current.id}`);
|
|
1455
|
+
return after;
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
try {
|
|
1458
|
+
this.db.exec("ROLLBACK");
|
|
1459
|
+
} catch {}
|
|
1460
|
+
throw error;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
|
|
1464
|
+
const normalized = normalizeCreateWorkflowInput(workflowInput);
|
|
1465
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
1466
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1467
|
+
try {
|
|
1468
|
+
const current = this.requireUniqueLoop(idOrName);
|
|
1469
|
+
if (current.target.type !== "workflow")
|
|
1470
|
+
throw new Error(`loop is not a workflow loop: ${idOrName}`);
|
|
1471
|
+
if (this.hasRunningRun(current.id))
|
|
1472
|
+
throw new Error(`refusing to retarget running loop: ${current.id}`);
|
|
1473
|
+
const previousWorkflow = this.requireWorkflow(current.target.workflowId);
|
|
1474
|
+
const workflow = {
|
|
1475
|
+
id: genId(),
|
|
1476
|
+
name: normalized.name,
|
|
1477
|
+
description: normalized.description,
|
|
1478
|
+
version: normalized.version ?? 1,
|
|
1479
|
+
status: "active",
|
|
1480
|
+
goal: normalized.goal,
|
|
1481
|
+
steps: normalized.steps,
|
|
1482
|
+
createdAt: updated,
|
|
1483
|
+
updatedAt: updated
|
|
1484
|
+
};
|
|
1485
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
|
|
1486
|
+
VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
|
|
1487
|
+
$id: workflow.id,
|
|
1488
|
+
$name: workflow.name,
|
|
1489
|
+
$description: workflow.description ?? null,
|
|
1490
|
+
$version: workflow.version,
|
|
1491
|
+
$status: workflow.status,
|
|
1492
|
+
$goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
|
|
1493
|
+
$steps: JSON.stringify(workflow.steps),
|
|
1494
|
+
$created: workflow.createdAt,
|
|
1495
|
+
$updated: workflow.updatedAt
|
|
1496
|
+
});
|
|
1497
|
+
const target = { ...current.target, workflowId: workflow.id };
|
|
1498
|
+
if (opts.workflowTimeoutMs !== undefined)
|
|
1499
|
+
target.timeoutMs = opts.workflowTimeoutMs;
|
|
1500
|
+
const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
|
|
1501
|
+
WHERE id=$id
|
|
1502
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1503
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1504
|
+
))`).run({
|
|
1505
|
+
$id: current.id,
|
|
1506
|
+
$target: JSON.stringify(target),
|
|
1507
|
+
$updated: updated,
|
|
1508
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1509
|
+
$now: updated
|
|
1510
|
+
});
|
|
1511
|
+
if (res.changes !== 1)
|
|
1512
|
+
throw new Error("daemon lease lost");
|
|
1513
|
+
const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
|
|
1514
|
+
this.db.exec("COMMIT");
|
|
1515
|
+
const loop = this.getLoop(current.id);
|
|
1516
|
+
if (!loop)
|
|
1517
|
+
throw new Error(`loop not found after retarget: ${current.id}`);
|
|
1518
|
+
return { loop, workflow, previousWorkflow, archivedOld };
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
try {
|
|
1521
|
+
this.db.exec("ROLLBACK");
|
|
1522
|
+
} catch {}
|
|
1523
|
+
throw error;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1382
1526
|
renameLoop(id, name, opts = {}) {
|
|
1383
1527
|
const current = this.getLoop(id);
|
|
1384
1528
|
if (!current)
|
package/dist/lib/templates.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary } from "../types.js";
|
|
1
|
+
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary, TimeoutMs } from "../types.js";
|
|
2
2
|
export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
3
3
|
export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
4
4
|
export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
@@ -40,6 +40,7 @@ export interface TodosTaskWorkflowTemplateInput {
|
|
|
40
40
|
worktreeMode?: AgentWorktreeMode;
|
|
41
41
|
worktreeRoot?: string;
|
|
42
42
|
worktreeBranchPrefix?: string;
|
|
43
|
+
timeoutMs?: TimeoutMs;
|
|
43
44
|
eventId?: string;
|
|
44
45
|
eventType?: string;
|
|
45
46
|
}
|
|
@@ -76,6 +77,7 @@ export interface EventWorkflowTemplateInput {
|
|
|
76
77
|
worktreeMode?: AgentWorktreeMode;
|
|
77
78
|
worktreeRoot?: string;
|
|
78
79
|
worktreeBranchPrefix?: string;
|
|
80
|
+
timeoutMs?: TimeoutMs;
|
|
79
81
|
}
|
|
80
82
|
export interface BoundedAgentWorkflowTemplateInput {
|
|
81
83
|
name?: string;
|
|
@@ -107,7 +109,7 @@ export interface BoundedAgentWorkflowTemplateInput {
|
|
|
107
109
|
worktreeMode?: AgentWorktreeMode;
|
|
108
110
|
worktreeRoot?: string;
|
|
109
111
|
worktreeBranchPrefix?: string;
|
|
110
|
-
timeoutMs?:
|
|
112
|
+
timeoutMs?: TimeoutMs;
|
|
111
113
|
}
|
|
112
114
|
export type LoopTemplateSourceFilter = LoopTemplateSource | "all";
|
|
113
115
|
export interface ListLoopTemplatesOptions {
|
package/dist/sdk/index.js
CHANGED
|
@@ -329,6 +329,15 @@ function optionalPositiveInteger(value, label) {
|
|
|
329
329
|
throw new Error(`${label} must be a positive integer`);
|
|
330
330
|
return value;
|
|
331
331
|
}
|
|
332
|
+
function optionalTimeoutMs(value, label) {
|
|
333
|
+
if (value === undefined)
|
|
334
|
+
return;
|
|
335
|
+
if (value === null)
|
|
336
|
+
return null;
|
|
337
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
338
|
+
throw new Error(`${label} must be a positive integer or null for unlimited`);
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
332
341
|
function optionalBoolean(value, label) {
|
|
333
342
|
if (value === undefined)
|
|
334
343
|
return;
|
|
@@ -417,7 +426,7 @@ function validateTarget(value, label, opts) {
|
|
|
417
426
|
assertObject(value, label);
|
|
418
427
|
if (value.type === "command") {
|
|
419
428
|
assertString(value.command, `${label}.command`);
|
|
420
|
-
|
|
429
|
+
optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
|
|
421
430
|
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
422
431
|
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
423
432
|
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
@@ -436,7 +445,7 @@ function validateTarget(value, label, opts) {
|
|
|
436
445
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
437
446
|
if (!providers.includes(value.provider))
|
|
438
447
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
439
|
-
|
|
448
|
+
optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
|
|
440
449
|
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
441
450
|
if (value.authProfile !== undefined) {
|
|
442
451
|
assertString(value.authProfile, `${label}.authProfile`);
|
|
@@ -559,7 +568,7 @@ function normalizeCreateWorkflowInput(input, opts = {}) {
|
|
|
559
568
|
target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
|
|
560
569
|
dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
|
|
561
570
|
continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
|
|
562
|
-
timeoutMs:
|
|
571
|
+
timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
|
|
563
572
|
account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
|
|
564
573
|
};
|
|
565
574
|
});
|
|
@@ -1305,6 +1314,17 @@ class Store {
|
|
|
1305
1314
|
const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
|
|
1306
1315
|
return row ? rowToLoop(row) : undefined;
|
|
1307
1316
|
}
|
|
1317
|
+
requireUniqueLoop(idOrName) {
|
|
1318
|
+
const byId = this.getLoop(idOrName);
|
|
1319
|
+
if (byId)
|
|
1320
|
+
return byId;
|
|
1321
|
+
const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
|
|
1322
|
+
if (rows.length === 0)
|
|
1323
|
+
throw new Error(`loop not found: ${idOrName}`);
|
|
1324
|
+
if (rows.length > 1)
|
|
1325
|
+
throw new Error(`ambiguous loop name: ${idOrName}; use a loop id`);
|
|
1326
|
+
return rowToLoop(rows[0]);
|
|
1327
|
+
}
|
|
1308
1328
|
requireLoop(idOrName) {
|
|
1309
1329
|
return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
|
|
1310
1330
|
throw new Error(`loop not found: ${idOrName}`);
|
|
@@ -1379,6 +1399,130 @@ class Store {
|
|
|
1379
1399
|
throw new Error(`loop not found after update: ${id}`);
|
|
1380
1400
|
return after;
|
|
1381
1401
|
}
|
|
1402
|
+
activeLoopReferenceCount(workflowId) {
|
|
1403
|
+
const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
|
|
1404
|
+
let count = 0;
|
|
1405
|
+
for (const row of rows) {
|
|
1406
|
+
try {
|
|
1407
|
+
const target = JSON.parse(row.target_json);
|
|
1408
|
+
if (target.type === "workflow" && target.workflowId === workflowId)
|
|
1409
|
+
count += 1;
|
|
1410
|
+
} catch {}
|
|
1411
|
+
}
|
|
1412
|
+
return count;
|
|
1413
|
+
}
|
|
1414
|
+
archiveWorkflowIfUnreferenced(workflowId, updated) {
|
|
1415
|
+
if (this.activeLoopReferenceCount(workflowId) > 0)
|
|
1416
|
+
return;
|
|
1417
|
+
const workflow = this.getWorkflow(workflowId);
|
|
1418
|
+
if (!workflow || workflow.status !== "active")
|
|
1419
|
+
return;
|
|
1420
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
|
|
1421
|
+
if (res.changes !== 1)
|
|
1422
|
+
return;
|
|
1423
|
+
return this.getWorkflow(workflowId);
|
|
1424
|
+
}
|
|
1425
|
+
retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
|
|
1426
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
1427
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1428
|
+
try {
|
|
1429
|
+
const current = this.requireLoop(idOrName);
|
|
1430
|
+
if (current.target.type !== "workflow")
|
|
1431
|
+
throw new Error(`loop is not a workflow loop: ${idOrName}`);
|
|
1432
|
+
if (this.hasRunningRun(current.id))
|
|
1433
|
+
throw new Error(`refusing to retarget running loop: ${current.id}`);
|
|
1434
|
+
const workflow = this.requireWorkflow(workflowId);
|
|
1435
|
+
const target = { ...current.target, workflowId: workflow.id };
|
|
1436
|
+
if (opts.workflowTimeoutMs !== undefined)
|
|
1437
|
+
target.timeoutMs = opts.workflowTimeoutMs;
|
|
1438
|
+
const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
|
|
1439
|
+
WHERE id=$id
|
|
1440
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1441
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1442
|
+
))`).run({
|
|
1443
|
+
$id: current.id,
|
|
1444
|
+
$target: JSON.stringify(target),
|
|
1445
|
+
$updated: updated,
|
|
1446
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1447
|
+
$now: updated
|
|
1448
|
+
});
|
|
1449
|
+
if (res.changes !== 1)
|
|
1450
|
+
throw new Error("daemon lease lost");
|
|
1451
|
+
this.db.exec("COMMIT");
|
|
1452
|
+
const after = this.getLoop(current.id);
|
|
1453
|
+
if (!after)
|
|
1454
|
+
throw new Error(`loop not found after retarget: ${current.id}`);
|
|
1455
|
+
return after;
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
try {
|
|
1458
|
+
this.db.exec("ROLLBACK");
|
|
1459
|
+
} catch {}
|
|
1460
|
+
throw error;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
|
|
1464
|
+
const normalized = normalizeCreateWorkflowInput(workflowInput);
|
|
1465
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
1466
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1467
|
+
try {
|
|
1468
|
+
const current = this.requireUniqueLoop(idOrName);
|
|
1469
|
+
if (current.target.type !== "workflow")
|
|
1470
|
+
throw new Error(`loop is not a workflow loop: ${idOrName}`);
|
|
1471
|
+
if (this.hasRunningRun(current.id))
|
|
1472
|
+
throw new Error(`refusing to retarget running loop: ${current.id}`);
|
|
1473
|
+
const previousWorkflow = this.requireWorkflow(current.target.workflowId);
|
|
1474
|
+
const workflow = {
|
|
1475
|
+
id: genId(),
|
|
1476
|
+
name: normalized.name,
|
|
1477
|
+
description: normalized.description,
|
|
1478
|
+
version: normalized.version ?? 1,
|
|
1479
|
+
status: "active",
|
|
1480
|
+
goal: normalized.goal,
|
|
1481
|
+
steps: normalized.steps,
|
|
1482
|
+
createdAt: updated,
|
|
1483
|
+
updatedAt: updated
|
|
1484
|
+
};
|
|
1485
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
|
|
1486
|
+
VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
|
|
1487
|
+
$id: workflow.id,
|
|
1488
|
+
$name: workflow.name,
|
|
1489
|
+
$description: workflow.description ?? null,
|
|
1490
|
+
$version: workflow.version,
|
|
1491
|
+
$status: workflow.status,
|
|
1492
|
+
$goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
|
|
1493
|
+
$steps: JSON.stringify(workflow.steps),
|
|
1494
|
+
$created: workflow.createdAt,
|
|
1495
|
+
$updated: workflow.updatedAt
|
|
1496
|
+
});
|
|
1497
|
+
const target = { ...current.target, workflowId: workflow.id };
|
|
1498
|
+
if (opts.workflowTimeoutMs !== undefined)
|
|
1499
|
+
target.timeoutMs = opts.workflowTimeoutMs;
|
|
1500
|
+
const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
|
|
1501
|
+
WHERE id=$id
|
|
1502
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1503
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1504
|
+
))`).run({
|
|
1505
|
+
$id: current.id,
|
|
1506
|
+
$target: JSON.stringify(target),
|
|
1507
|
+
$updated: updated,
|
|
1508
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1509
|
+
$now: updated
|
|
1510
|
+
});
|
|
1511
|
+
if (res.changes !== 1)
|
|
1512
|
+
throw new Error("daemon lease lost");
|
|
1513
|
+
const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
|
|
1514
|
+
this.db.exec("COMMIT");
|
|
1515
|
+
const loop = this.getLoop(current.id);
|
|
1516
|
+
if (!loop)
|
|
1517
|
+
throw new Error(`loop not found after retarget: ${current.id}`);
|
|
1518
|
+
return { loop, workflow, previousWorkflow, archivedOld };
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
try {
|
|
1521
|
+
this.db.exec("ROLLBACK");
|
|
1522
|
+
} catch {}
|
|
1523
|
+
throw error;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1382
1526
|
renameLoop(id, name, opts = {}) {
|
|
1383
1527
|
const current = this.getLoop(id);
|
|
1384
1528
|
if (!current)
|
|
@@ -3480,7 +3624,7 @@ function commandSpec(target) {
|
|
|
3480
3624
|
cwd: commandTarget.cwd,
|
|
3481
3625
|
shell: commandTarget.shell,
|
|
3482
3626
|
env: commandTarget.env,
|
|
3483
|
-
timeoutMs: commandTarget.timeoutMs
|
|
3627
|
+
timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
|
|
3484
3628
|
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
3485
3629
|
account: commandTarget.account,
|
|
3486
3630
|
accountTool: commandTarget.account?.tool
|
|
@@ -3491,7 +3635,7 @@ function commandSpec(target) {
|
|
|
3491
3635
|
command: providerCommand(agentTarget.provider),
|
|
3492
3636
|
args: agentArgs(agentTarget),
|
|
3493
3637
|
cwd: agentTarget.cwd,
|
|
3494
|
-
timeoutMs: agentTarget.timeoutMs ??
|
|
3638
|
+
timeoutMs: agentTarget.timeoutMs ?? null,
|
|
3495
3639
|
idleTimeoutMs: agentTarget.idleTimeoutMs,
|
|
3496
3640
|
account: agentTarget.account,
|
|
3497
3641
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
@@ -3679,12 +3823,12 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3679
3823
|
if (opts.signal?.aborted)
|
|
3680
3824
|
abortHandler();
|
|
3681
3825
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3682
|
-
const timer = setTimeout(() => {
|
|
3826
|
+
const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
|
|
3683
3827
|
timedOut = true;
|
|
3684
3828
|
if (child.pid)
|
|
3685
3829
|
killProcessGroup(child.pid);
|
|
3686
|
-
}, spec.timeoutMs);
|
|
3687
|
-
timer
|
|
3830
|
+
}, spec.timeoutMs) : undefined;
|
|
3831
|
+
timer?.unref();
|
|
3688
3832
|
let idleTimer;
|
|
3689
3833
|
const resetIdleTimer = () => {
|
|
3690
3834
|
if (!spec.idleTimeoutMs)
|
|
@@ -3716,7 +3860,8 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3716
3860
|
} catch (err) {
|
|
3717
3861
|
error = err instanceof Error ? err.message : String(err);
|
|
3718
3862
|
} finally {
|
|
3719
|
-
|
|
3863
|
+
if (timer)
|
|
3864
|
+
clearTimeout(timer);
|
|
3720
3865
|
if (idleTimer)
|
|
3721
3866
|
clearTimeout(idleTimer);
|
|
3722
3867
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
@@ -3858,12 +4003,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3858
4003
|
if (opts.signal?.aborted)
|
|
3859
4004
|
abortHandler();
|
|
3860
4005
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3861
|
-
const timer = setTimeout(() => {
|
|
4006
|
+
const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
|
|
3862
4007
|
timedOut = true;
|
|
3863
4008
|
if (child.pid)
|
|
3864
4009
|
killProcessGroup(child.pid);
|
|
3865
|
-
}, spec.timeoutMs);
|
|
3866
|
-
timer
|
|
4010
|
+
}, spec.timeoutMs) : undefined;
|
|
4011
|
+
timer?.unref();
|
|
3867
4012
|
let idleTimer;
|
|
3868
4013
|
const resetIdleTimer = () => {
|
|
3869
4014
|
if (!spec.idleTimeoutMs)
|
|
@@ -3895,7 +4040,8 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3895
4040
|
} catch (err) {
|
|
3896
4041
|
error = err instanceof Error ? err.message : String(err);
|
|
3897
4042
|
} finally {
|
|
3898
|
-
|
|
4043
|
+
if (timer)
|
|
4044
|
+
clearTimeout(timer);
|
|
3899
4045
|
if (idleTimer)
|
|
3900
4046
|
clearTimeout(idleTimer);
|
|
3901
4047
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
@@ -4320,7 +4466,7 @@ ${result.stderr}`);
|
|
|
4320
4466
|
// src/lib/workflow-runner.ts
|
|
4321
4467
|
function targetWithStepAccount(step) {
|
|
4322
4468
|
const account = step.account ?? step.target.account;
|
|
4323
|
-
const timeoutMs = step.timeoutMs
|
|
4469
|
+
const timeoutMs = step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs;
|
|
4324
4470
|
if (!account && timeoutMs === step.target.timeoutMs)
|
|
4325
4471
|
return step.target;
|
|
4326
4472
|
return { ...step.target, account, timeoutMs };
|
|
@@ -4616,7 +4762,8 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4616
4762
|
})
|
|
4617
4763
|
});
|
|
4618
4764
|
}
|
|
4619
|
-
const
|
|
4765
|
+
const workflowTimeoutMs = typeof loop.target.timeoutMs === "number" ? loop.target.timeoutMs : undefined;
|
|
4766
|
+
const controller = workflowTimeoutMs !== undefined ? new AbortController : undefined;
|
|
4620
4767
|
let workflowTimedOut = false;
|
|
4621
4768
|
const externalAbort = () => controller?.abort();
|
|
4622
4769
|
if (controller && opts.signal?.aborted)
|
|
@@ -4626,13 +4773,13 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4626
4773
|
const timer = controller ? setTimeout(() => {
|
|
4627
4774
|
workflowTimedOut = true;
|
|
4628
4775
|
controller.abort();
|
|
4629
|
-
},
|
|
4776
|
+
}, workflowTimeoutMs) : undefined;
|
|
4630
4777
|
timer?.unref();
|
|
4631
4778
|
try {
|
|
4632
4779
|
return await executeWorkflow(store, workflow, {
|
|
4633
4780
|
...opts,
|
|
4634
4781
|
signal: controller?.signal ?? opts.signal,
|
|
4635
|
-
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${
|
|
4782
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${workflowTimeoutMs}ms` : undefined,
|
|
4636
4783
|
loop,
|
|
4637
4784
|
loopRun: run,
|
|
4638
4785
|
scheduledFor: run.scheduledFor,
|
package/dist/types.d.ts
CHANGED
|
@@ -70,11 +70,12 @@ export interface CommandTarget {
|
|
|
70
70
|
cwd?: string;
|
|
71
71
|
shell?: boolean;
|
|
72
72
|
env?: Record<string, string>;
|
|
73
|
-
timeoutMs?:
|
|
73
|
+
timeoutMs?: TimeoutMs;
|
|
74
74
|
idleTimeoutMs?: number;
|
|
75
75
|
account?: AccountRef;
|
|
76
76
|
preflight?: RuntimePreflightPolicy;
|
|
77
77
|
}
|
|
78
|
+
export type TimeoutMs = number | null;
|
|
78
79
|
export type AgentProvider = "claude" | "cursor" | "codewith" | "aicopilot" | "opencode" | "codex";
|
|
79
80
|
export type AgentConfigIsolation = "safe" | "none";
|
|
80
81
|
export type AgentPermissionMode = "default" | "plan" | "auto" | "bypass";
|
|
@@ -118,7 +119,7 @@ export interface AgentTargetBase {
|
|
|
118
119
|
authProfile?: string;
|
|
119
120
|
extraArgs?: string[];
|
|
120
121
|
addDirs?: string[];
|
|
121
|
-
timeoutMs?:
|
|
122
|
+
timeoutMs?: TimeoutMs;
|
|
122
123
|
idleTimeoutMs?: number;
|
|
123
124
|
configIsolation?: AgentConfigIsolation;
|
|
124
125
|
permissionMode?: AgentPermissionMode;
|
|
@@ -140,7 +141,7 @@ export interface WorkflowTarget {
|
|
|
140
141
|
type: "workflow";
|
|
141
142
|
workflowId: string;
|
|
142
143
|
input?: Record<string, string>;
|
|
143
|
-
timeoutMs?:
|
|
144
|
+
timeoutMs?: TimeoutMs;
|
|
144
145
|
preflight?: RuntimePreflightPolicy;
|
|
145
146
|
}
|
|
146
147
|
export type ExecutableTarget = CommandTarget | AgentTarget;
|
|
@@ -242,7 +243,7 @@ export interface WorkflowStep {
|
|
|
242
243
|
goal?: GoalSpec;
|
|
243
244
|
dependsOn?: string[];
|
|
244
245
|
continueOnFailure?: boolean;
|
|
245
|
-
timeoutMs?:
|
|
246
|
+
timeoutMs?: TimeoutMs;
|
|
246
247
|
account?: AccountRef;
|
|
247
248
|
}
|
|
248
249
|
export interface WorkflowStepInput extends Omit<WorkflowStep, "target"> {
|
package/docs/USAGE.md
CHANGED
|
@@ -245,6 +245,11 @@ loops workflows recover <workflow-run-id>
|
|
|
245
245
|
loops create workflow repo-morning-loop --workflow repo-morning --cron "0 8 * * *"
|
|
246
246
|
```
|
|
247
247
|
|
|
248
|
+
Use `recover` only for interrupted `running` workflow runs whose recorded child
|
|
249
|
+
process is gone. Terminal `timed_out` task/event workflow runs are audit
|
|
250
|
+
history; requeue them through the original task/event route after fixing the
|
|
251
|
+
cause.
|
|
252
|
+
|
|
248
253
|
Workflow specs are stored separately from loops. A loop can schedule a workflow, but workflow runs and step runs have their own durable rows and events. Steps run in dependency order and a scheduled workflow run is idempotent per loop slot.
|
|
249
254
|
|
|
250
255
|
For command steps, `command` is the executable when `shell` is not true. Put flags in `args`:
|
|
@@ -310,6 +315,30 @@ Custom reusable workflow templates live under the OpenLoops app data directory:
|
|
|
310
315
|
showing, and rendering templates never executes workflow steps or mutates the
|
|
311
316
|
registry.
|
|
312
317
|
|
|
318
|
+
Timeout policy is explicit. Deterministic command/check steps should normally
|
|
319
|
+
keep finite `timeoutMs`/`idleTimeoutMs` guards so broken shell work cannot run
|
|
320
|
+
forever. Agentic work steps default to no timeout in built-in worker/verifier
|
|
321
|
+
and task-lifecycle templates; use `timeoutMs: null` in workflow JSON, or
|
|
322
|
+
`--timeout none` / `--timeout unlimited` for CLI-created targets, when a step
|
|
323
|
+
may need hours or days. Use a positive numeric `timeoutMs` only when an agentic
|
|
324
|
+
step is intentionally bounded.
|
|
325
|
+
|
|
326
|
+
To migrate existing workflow loops, do not edit `workflow_specs.steps_json`
|
|
327
|
+
directly because historical workflow runs must keep pointing at their original
|
|
328
|
+
spec. Use the append-only migrator:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
loops workflows migrate-agent-timeouts --loop <loop-id-or-name>
|
|
332
|
+
loops workflows migrate-agent-timeouts --loop <loop-id-or-name> --apply
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The command dry-runs by default. With `--apply`, it creates a new workflow spec
|
|
336
|
+
with the requested agent timeout policy, retargets only future executions of
|
|
337
|
+
eligible non-running workflow loops, and leaves terminal timed-out workflow runs
|
|
338
|
+
as audit history. Use `loops workflows recover` only for interrupted `running`
|
|
339
|
+
workflow runs whose recorded child process is gone; terminal `timed_out` runs
|
|
340
|
+
must be requeued by re-delivering or draining the original task/event route.
|
|
341
|
+
|
|
313
342
|
```json
|
|
314
343
|
{
|
|
315
344
|
"id": "custom-report",
|
|
@@ -318,8 +347,7 @@ registry.
|
|
|
318
347
|
"kind": "workflow",
|
|
319
348
|
"variables": [
|
|
320
349
|
{ "name": "objective", "required": true, "description": "Report objective." },
|
|
321
|
-
{ "name": "projectPath", "required": true, "description": "Working directory." }
|
|
322
|
-
{ "name": "timeoutMs", "default": "300000", "type": "number" }
|
|
350
|
+
{ "name": "projectPath", "required": true, "description": "Working directory." }
|
|
323
351
|
],
|
|
324
352
|
"workflow": {
|
|
325
353
|
"name": "custom-report-${objective}",
|
|
@@ -334,9 +362,9 @@ registry.
|
|
|
334
362
|
"configIsolation": "safe",
|
|
335
363
|
"permissionMode": "bypass",
|
|
336
364
|
"sandbox": "workspace-write",
|
|
337
|
-
"timeoutMs":
|
|
365
|
+
"timeoutMs": null
|
|
338
366
|
},
|
|
339
|
-
"timeoutMs":
|
|
367
|
+
"timeoutMs": null
|
|
340
368
|
}
|
|
341
369
|
]
|
|
342
370
|
}
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"type": "agent",
|
|
49
49
|
"provider": "codewith",
|
|
50
50
|
"cwd": "/path/to/repo",
|
|
51
|
-
"timeoutMs":
|
|
51
|
+
"timeoutMs": null,
|
|
52
52
|
"prompt": "Read .openloops/transcripts/latest-transcript.json and create or update docs/loop-backlog.md. Extract only recurring work that is useful enough to schedule through OpenLoops. For each candidate include cadence, target provider, allowed write scope, expected artifacts, verification command, stop condition, and the specific transcript insight that justifies it. Prefer loops for code review/security, customer feedback triage, maintenance PRs, CI optimization, knowledge capture, and workflow hygiene."
|
|
53
53
|
}
|
|
54
54
|
},
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"type": "agent",
|
|
61
61
|
"provider": "codewith",
|
|
62
62
|
"cwd": "/path/to/repo",
|
|
63
|
-
"timeoutMs":
|
|
63
|
+
"timeoutMs": null,
|
|
64
64
|
"prompt": "Use docs/loop-backlog.md to author or update workflow JSON specs under docs/workflows/generated. Each workflow must use deterministic command steps for checks, agent steps for judgment or code changes, and workflow or step goals only when the outcome needs planning. Keep prompts scoped to the target repo, name allowed paths, and include validation evidence."
|
|
65
65
|
}
|
|
66
66
|
},
|
package/package.json
CHANGED