@hasna/loops 0.3.53 → 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 +77 -4
- package/dist/cli/index.js +477 -108
- package/dist/daemon/index.js +223 -29
- package/dist/index.js +308 -75
- package/dist/lib/store.d.ts +16 -1
- package/dist/lib/store.js +202 -11
- package/dist/lib/templates.d.ts +4 -2
- package/dist/lib/workflow-spec.d.ts +8 -2
- package/dist/sdk/index.js +219 -25
- package/dist/types.d.ts +24 -8
- package/docs/USAGE.md +82 -4
- package/docs/workflows/transcript-feedback-to-loops.json +2 -2
- package/package.json +1 -1
package/dist/sdk/index.js
CHANGED
|
@@ -312,6 +312,8 @@ function updateReadyFlags(nodes, planStatus) {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
// src/lib/workflow-spec.ts
|
|
315
|
+
import { readFileSync } from "fs";
|
|
316
|
+
import { isAbsolute, resolve } from "path";
|
|
315
317
|
function assertObject(value, label) {
|
|
316
318
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
317
319
|
throw new Error(`${label} must be an object`);
|
|
@@ -327,6 +329,15 @@ function optionalPositiveInteger(value, label) {
|
|
|
327
329
|
throw new Error(`${label} must be a positive integer`);
|
|
328
330
|
return value;
|
|
329
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
|
+
}
|
|
330
341
|
function optionalBoolean(value, label) {
|
|
331
342
|
if (value === undefined)
|
|
332
343
|
return;
|
|
@@ -357,6 +368,38 @@ function optionalAccountRef(value, label) {
|
|
|
357
368
|
tool: typeof value.tool === "string" ? value.tool.trim() : undefined
|
|
358
369
|
};
|
|
359
370
|
}
|
|
371
|
+
function promptFilePath(rawPath, opts) {
|
|
372
|
+
return isAbsolute(rawPath) ? resolve(rawPath) : resolve(opts.baseDir ?? process.cwd(), rawPath);
|
|
373
|
+
}
|
|
374
|
+
function readPromptFile(rawPath, label, opts) {
|
|
375
|
+
assertString(rawPath, `${label}.promptFile`);
|
|
376
|
+
const path = promptFilePath(rawPath.trim(), opts);
|
|
377
|
+
let prompt;
|
|
378
|
+
try {
|
|
379
|
+
prompt = readFileSync(path, "utf8");
|
|
380
|
+
} catch (error) {
|
|
381
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
|
382
|
+
throw new Error(`${label}.promptFile could not be read${code ? `: ${code}` : ""}`);
|
|
383
|
+
}
|
|
384
|
+
if (prompt.trim() === "")
|
|
385
|
+
throw new Error(`${label}.promptFile must contain a non-empty prompt`);
|
|
386
|
+
return { prompt, promptSource: { type: "file", path } };
|
|
387
|
+
}
|
|
388
|
+
function matchingPromptSource(value, prompt, opts) {
|
|
389
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
390
|
+
return;
|
|
391
|
+
if (value.type !== "file")
|
|
392
|
+
return;
|
|
393
|
+
const rawPath = value.path;
|
|
394
|
+
if (typeof rawPath !== "string" || rawPath.trim() === "")
|
|
395
|
+
return;
|
|
396
|
+
const path = promptFilePath(rawPath.trim(), opts);
|
|
397
|
+
try {
|
|
398
|
+
return readFileSync(path, "utf8") === prompt ? { type: "file", path } : undefined;
|
|
399
|
+
} catch {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
360
403
|
function normalizeGoalSpec(value, label = "goal") {
|
|
361
404
|
if (value === undefined)
|
|
362
405
|
return;
|
|
@@ -379,11 +422,11 @@ function normalizeGoalSpec(value, label = "goal") {
|
|
|
379
422
|
autoExecute
|
|
380
423
|
};
|
|
381
424
|
}
|
|
382
|
-
function validateTarget(value, label) {
|
|
425
|
+
function validateTarget(value, label, opts) {
|
|
383
426
|
assertObject(value, label);
|
|
384
427
|
if (value.type === "command") {
|
|
385
428
|
assertString(value.command, `${label}.command`);
|
|
386
|
-
|
|
429
|
+
optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
|
|
387
430
|
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
388
431
|
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
389
432
|
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
@@ -392,11 +435,17 @@ function validateTarget(value, label) {
|
|
|
392
435
|
}
|
|
393
436
|
if (value.type === "agent") {
|
|
394
437
|
assertString(value.provider, `${label}.provider`);
|
|
395
|
-
|
|
438
|
+
const hasPrompt = typeof value.prompt === "string" && value.prompt.trim() !== "";
|
|
439
|
+
const hasPromptFile = typeof value.promptFile === "string" && value.promptFile.trim() !== "";
|
|
440
|
+
if (hasPrompt && hasPromptFile)
|
|
441
|
+
throw new Error(`${label} must use either prompt or promptFile, not both`);
|
|
442
|
+
if (!hasPrompt && !hasPromptFile)
|
|
443
|
+
throw new Error(`${label}.prompt must be a non-empty string or ${label}.promptFile must be set`);
|
|
444
|
+
const promptFields = hasPrompt ? { prompt: value.prompt, promptSource: matchingPromptSource(value.promptSource, value.prompt, opts) } : readPromptFile(value.promptFile, label, opts);
|
|
396
445
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
397
446
|
if (!providers.includes(value.provider))
|
|
398
447
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
399
|
-
|
|
448
|
+
optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
|
|
400
449
|
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
401
450
|
if (value.authProfile !== undefined) {
|
|
402
451
|
assertString(value.authProfile, `${label}.authProfile`);
|
|
@@ -493,11 +542,14 @@ function validateTarget(value, label) {
|
|
|
493
542
|
if (value.routing.eventSource !== undefined)
|
|
494
543
|
assertString(value.routing.eventSource, `${label}.routing.eventSource`);
|
|
495
544
|
}
|
|
496
|
-
|
|
545
|
+
const target = { ...value };
|
|
546
|
+
delete target.promptFile;
|
|
547
|
+
delete target.promptSource;
|
|
548
|
+
return { ...target, ...promptFields };
|
|
497
549
|
}
|
|
498
550
|
throw new Error(`${label}.type must be command or agent`);
|
|
499
551
|
}
|
|
500
|
-
function normalizeCreateWorkflowInput(input) {
|
|
552
|
+
function normalizeCreateWorkflowInput(input, opts = {}) {
|
|
501
553
|
assertString(input.name, "workflow.name");
|
|
502
554
|
const goal = normalizeGoalSpec(input.goal, "goal");
|
|
503
555
|
if (!Array.isArray(input.steps) || input.steps.length === 0)
|
|
@@ -513,10 +565,10 @@ function normalizeCreateWorkflowInput(input) {
|
|
|
513
565
|
...step,
|
|
514
566
|
id: step.id,
|
|
515
567
|
goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
|
|
516
|
-
target: validateTarget(step.target, `workflow.steps[${index}].target
|
|
568
|
+
target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
|
|
517
569
|
dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
|
|
518
570
|
continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
|
|
519
|
-
timeoutMs:
|
|
571
|
+
timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
|
|
520
572
|
account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
|
|
521
573
|
};
|
|
522
574
|
});
|
|
@@ -556,7 +608,7 @@ function workflowExecutionOrder(workflow) {
|
|
|
556
608
|
visit(step);
|
|
557
609
|
return order;
|
|
558
610
|
}
|
|
559
|
-
function workflowBodyFromJson(value, fallbackName) {
|
|
611
|
+
function workflowBodyFromJson(value, fallbackName, opts = {}) {
|
|
560
612
|
assertObject(value, "workflow file");
|
|
561
613
|
const rawName = fallbackName ?? value.name;
|
|
562
614
|
assertString(rawName, "workflow.name");
|
|
@@ -568,7 +620,7 @@ function workflowBodyFromJson(value, fallbackName) {
|
|
|
568
620
|
goal: normalizeGoalSpec(value.goal, "goal"),
|
|
569
621
|
version: typeof value.version === "number" ? value.version : undefined,
|
|
570
622
|
steps: value.steps
|
|
571
|
-
});
|
|
623
|
+
}, opts);
|
|
572
624
|
}
|
|
573
625
|
|
|
574
626
|
// src/lib/run-artifacts.ts
|
|
@@ -1205,13 +1257,17 @@ class Store {
|
|
|
1205
1257
|
}
|
|
1206
1258
|
createLoop(input, from = new Date) {
|
|
1207
1259
|
const now = nowIso();
|
|
1260
|
+
const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
|
|
1261
|
+
name: "loop-target-validation",
|
|
1262
|
+
steps: [{ id: "target", target: input.target }]
|
|
1263
|
+
}).steps[0].target;
|
|
1208
1264
|
const loop = {
|
|
1209
1265
|
id: genId(),
|
|
1210
1266
|
name: input.name,
|
|
1211
1267
|
description: input.description,
|
|
1212
1268
|
status: "active",
|
|
1213
1269
|
schedule: input.schedule,
|
|
1214
|
-
target
|
|
1270
|
+
target,
|
|
1215
1271
|
goal: input.goal,
|
|
1216
1272
|
machine: input.machine,
|
|
1217
1273
|
nextRunAt: initialNextRun(input.schedule, from),
|
|
@@ -1258,6 +1314,17 @@ class Store {
|
|
|
1258
1314
|
const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
|
|
1259
1315
|
return row ? rowToLoop(row) : undefined;
|
|
1260
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
|
+
}
|
|
1261
1328
|
requireLoop(idOrName) {
|
|
1262
1329
|
return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
|
|
1263
1330
|
throw new Error(`loop not found: ${idOrName}`);
|
|
@@ -1332,6 +1399,130 @@ class Store {
|
|
|
1332
1399
|
throw new Error(`loop not found after update: ${id}`);
|
|
1333
1400
|
return after;
|
|
1334
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
|
+
}
|
|
1335
1526
|
renameLoop(id, name, opts = {}) {
|
|
1336
1527
|
const current = this.getLoop(id);
|
|
1337
1528
|
if (!current)
|
|
@@ -3433,7 +3624,7 @@ function commandSpec(target) {
|
|
|
3433
3624
|
cwd: commandTarget.cwd,
|
|
3434
3625
|
shell: commandTarget.shell,
|
|
3435
3626
|
env: commandTarget.env,
|
|
3436
|
-
timeoutMs: commandTarget.timeoutMs
|
|
3627
|
+
timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
|
|
3437
3628
|
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
3438
3629
|
account: commandTarget.account,
|
|
3439
3630
|
accountTool: commandTarget.account?.tool
|
|
@@ -3444,7 +3635,7 @@ function commandSpec(target) {
|
|
|
3444
3635
|
command: providerCommand(agentTarget.provider),
|
|
3445
3636
|
args: agentArgs(agentTarget),
|
|
3446
3637
|
cwd: agentTarget.cwd,
|
|
3447
|
-
timeoutMs: agentTarget.timeoutMs ??
|
|
3638
|
+
timeoutMs: agentTarget.timeoutMs ?? null,
|
|
3448
3639
|
idleTimeoutMs: agentTarget.idleTimeoutMs,
|
|
3449
3640
|
account: agentTarget.account,
|
|
3450
3641
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
@@ -3632,12 +3823,12 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3632
3823
|
if (opts.signal?.aborted)
|
|
3633
3824
|
abortHandler();
|
|
3634
3825
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3635
|
-
const timer = setTimeout(() => {
|
|
3826
|
+
const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
|
|
3636
3827
|
timedOut = true;
|
|
3637
3828
|
if (child.pid)
|
|
3638
3829
|
killProcessGroup(child.pid);
|
|
3639
|
-
}, spec.timeoutMs);
|
|
3640
|
-
timer
|
|
3830
|
+
}, spec.timeoutMs) : undefined;
|
|
3831
|
+
timer?.unref();
|
|
3641
3832
|
let idleTimer;
|
|
3642
3833
|
const resetIdleTimer = () => {
|
|
3643
3834
|
if (!spec.idleTimeoutMs)
|
|
@@ -3669,7 +3860,8 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3669
3860
|
} catch (err) {
|
|
3670
3861
|
error = err instanceof Error ? err.message : String(err);
|
|
3671
3862
|
} finally {
|
|
3672
|
-
|
|
3863
|
+
if (timer)
|
|
3864
|
+
clearTimeout(timer);
|
|
3673
3865
|
if (idleTimer)
|
|
3674
3866
|
clearTimeout(idleTimer);
|
|
3675
3867
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
@@ -3811,12 +4003,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3811
4003
|
if (opts.signal?.aborted)
|
|
3812
4004
|
abortHandler();
|
|
3813
4005
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3814
|
-
const timer = setTimeout(() => {
|
|
4006
|
+
const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
|
|
3815
4007
|
timedOut = true;
|
|
3816
4008
|
if (child.pid)
|
|
3817
4009
|
killProcessGroup(child.pid);
|
|
3818
|
-
}, spec.timeoutMs);
|
|
3819
|
-
timer
|
|
4010
|
+
}, spec.timeoutMs) : undefined;
|
|
4011
|
+
timer?.unref();
|
|
3820
4012
|
let idleTimer;
|
|
3821
4013
|
const resetIdleTimer = () => {
|
|
3822
4014
|
if (!spec.idleTimeoutMs)
|
|
@@ -3848,7 +4040,8 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3848
4040
|
} catch (err) {
|
|
3849
4041
|
error = err instanceof Error ? err.message : String(err);
|
|
3850
4042
|
} finally {
|
|
3851
|
-
|
|
4043
|
+
if (timer)
|
|
4044
|
+
clearTimeout(timer);
|
|
3852
4045
|
if (idleTimer)
|
|
3853
4046
|
clearTimeout(idleTimer);
|
|
3854
4047
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
@@ -4273,7 +4466,7 @@ ${result.stderr}`);
|
|
|
4273
4466
|
// src/lib/workflow-runner.ts
|
|
4274
4467
|
function targetWithStepAccount(step) {
|
|
4275
4468
|
const account = step.account ?? step.target.account;
|
|
4276
|
-
const timeoutMs = step.timeoutMs
|
|
4469
|
+
const timeoutMs = step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs;
|
|
4277
4470
|
if (!account && timeoutMs === step.target.timeoutMs)
|
|
4278
4471
|
return step.target;
|
|
4279
4472
|
return { ...step.target, account, timeoutMs };
|
|
@@ -4569,7 +4762,8 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4569
4762
|
})
|
|
4570
4763
|
});
|
|
4571
4764
|
}
|
|
4572
|
-
const
|
|
4765
|
+
const workflowTimeoutMs = typeof loop.target.timeoutMs === "number" ? loop.target.timeoutMs : undefined;
|
|
4766
|
+
const controller = workflowTimeoutMs !== undefined ? new AbortController : undefined;
|
|
4573
4767
|
let workflowTimedOut = false;
|
|
4574
4768
|
const externalAbort = () => controller?.abort();
|
|
4575
4769
|
if (controller && opts.signal?.aborted)
|
|
@@ -4579,13 +4773,13 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4579
4773
|
const timer = controller ? setTimeout(() => {
|
|
4580
4774
|
workflowTimedOut = true;
|
|
4581
4775
|
controller.abort();
|
|
4582
|
-
},
|
|
4776
|
+
}, workflowTimeoutMs) : undefined;
|
|
4583
4777
|
timer?.unref();
|
|
4584
4778
|
try {
|
|
4585
4779
|
return await executeWorkflow(store, workflow, {
|
|
4586
4780
|
...opts,
|
|
4587
4781
|
signal: controller?.signal ?? opts.signal,
|
|
4588
|
-
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${
|
|
4782
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${workflowTimeoutMs}ms` : undefined,
|
|
4589
4783
|
loop,
|
|
4590
4784
|
loopRun: run,
|
|
4591
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";
|
|
@@ -104,10 +105,13 @@ export interface AgentRoutingSpec {
|
|
|
104
105
|
eventType?: string;
|
|
105
106
|
eventSource?: string;
|
|
106
107
|
}
|
|
107
|
-
export interface
|
|
108
|
+
export interface AgentPromptSource {
|
|
109
|
+
type: "file";
|
|
110
|
+
path: string;
|
|
111
|
+
}
|
|
112
|
+
export interface AgentTargetBase {
|
|
108
113
|
type: "agent";
|
|
109
114
|
provider: AgentProvider;
|
|
110
|
-
prompt: string;
|
|
111
115
|
cwd?: string;
|
|
112
116
|
model?: string;
|
|
113
117
|
variant?: string;
|
|
@@ -115,7 +119,7 @@ export interface AgentTarget {
|
|
|
115
119
|
authProfile?: string;
|
|
116
120
|
extraArgs?: string[];
|
|
117
121
|
addDirs?: string[];
|
|
118
|
-
timeoutMs?:
|
|
122
|
+
timeoutMs?: TimeoutMs;
|
|
119
123
|
idleTimeoutMs?: number;
|
|
120
124
|
configIsolation?: AgentConfigIsolation;
|
|
121
125
|
permissionMode?: AgentPermissionMode;
|
|
@@ -126,15 +130,24 @@ export interface AgentTarget {
|
|
|
126
130
|
account?: AccountRef;
|
|
127
131
|
preflight?: RuntimePreflightPolicy;
|
|
128
132
|
}
|
|
133
|
+
export interface AgentTarget extends AgentTargetBase {
|
|
134
|
+
prompt: string;
|
|
135
|
+
promptSource?: AgentPromptSource;
|
|
136
|
+
}
|
|
137
|
+
export interface PromptFileAgentTarget extends AgentTargetBase {
|
|
138
|
+
promptFile: string;
|
|
139
|
+
}
|
|
129
140
|
export interface WorkflowTarget {
|
|
130
141
|
type: "workflow";
|
|
131
142
|
workflowId: string;
|
|
132
143
|
input?: Record<string, string>;
|
|
133
|
-
timeoutMs?:
|
|
144
|
+
timeoutMs?: TimeoutMs;
|
|
134
145
|
preflight?: RuntimePreflightPolicy;
|
|
135
146
|
}
|
|
136
147
|
export type ExecutableTarget = CommandTarget | AgentTarget;
|
|
148
|
+
export type ExecutableTargetInput = CommandTarget | AgentTarget | PromptFileAgentTarget;
|
|
137
149
|
export type LoopTarget = ExecutableTarget | WorkflowTarget;
|
|
150
|
+
export type LoopTargetInput = ExecutableTargetInput | WorkflowTarget;
|
|
138
151
|
export type WorkflowStatus = "active" | "archived";
|
|
139
152
|
export type WorkflowRunStatus = "running" | "succeeded" | "failed" | "timed_out" | "cancelled";
|
|
140
153
|
export type WorkflowStepRunStatus = "pending" | "running" | "succeeded" | "failed" | "timed_out" | "skipped" | "cancelled";
|
|
@@ -230,9 +243,12 @@ export interface WorkflowStep {
|
|
|
230
243
|
goal?: GoalSpec;
|
|
231
244
|
dependsOn?: string[];
|
|
232
245
|
continueOnFailure?: boolean;
|
|
233
|
-
timeoutMs?:
|
|
246
|
+
timeoutMs?: TimeoutMs;
|
|
234
247
|
account?: AccountRef;
|
|
235
248
|
}
|
|
249
|
+
export interface WorkflowStepInput extends Omit<WorkflowStep, "target"> {
|
|
250
|
+
target: ExecutableTargetInput;
|
|
251
|
+
}
|
|
236
252
|
export interface WorkflowSpec {
|
|
237
253
|
id: string;
|
|
238
254
|
name: string;
|
|
@@ -248,7 +264,7 @@ export interface CreateWorkflowInput {
|
|
|
248
264
|
name: string;
|
|
249
265
|
description?: string;
|
|
250
266
|
goal?: GoalSpec;
|
|
251
|
-
steps:
|
|
267
|
+
steps: WorkflowStepInput[];
|
|
252
268
|
version?: number;
|
|
253
269
|
}
|
|
254
270
|
export type LoopTemplateKind = "workflow" | "loop";
|
|
@@ -367,7 +383,7 @@ export interface CreateLoopInput {
|
|
|
367
383
|
name: string;
|
|
368
384
|
description?: string;
|
|
369
385
|
schedule: ScheduleSpec;
|
|
370
|
-
target:
|
|
386
|
+
target: LoopTargetInput;
|
|
371
387
|
goal?: GoalSpec;
|
|
372
388
|
machine?: LoopMachineRef;
|
|
373
389
|
catchUp?: CatchUpPolicy;
|
package/docs/USAGE.md
CHANGED
|
@@ -148,6 +148,56 @@ accounts tools add codewith --label "Codewith" --env-var CODEWITH_HOME --bin cod
|
|
|
148
148
|
accounts tools add aicopilot --label "AI Copilot" --env-var AICOPILOT_CONFIG_DIR --bin aicopilot
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
+
## Prompt Files
|
|
152
|
+
|
|
153
|
+
Use prompt files for production agent prompts instead of long inline strings.
|
|
154
|
+
This keeps the durable prompt source reviewable and prevents shell history from
|
|
155
|
+
becoming the only copy of the prompt.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
mkdir -p ~/.hasna/loops/prompts
|
|
159
|
+
$EDITOR ~/.hasna/loops/prompts/repo-morning-check.md
|
|
160
|
+
|
|
161
|
+
loops create agent morning-check \
|
|
162
|
+
--provider codewith \
|
|
163
|
+
--auth-profile account001 \
|
|
164
|
+
--cron "0 8 * * *" \
|
|
165
|
+
--cwd /path/to/repo \
|
|
166
|
+
--prompt-file ~/.hasna/loops/prompts/repo-morning-check.md
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Workflow JSON supports `promptFile` on agent targets. Relative paths resolve
|
|
170
|
+
from the workflow JSON file's directory:
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{
|
|
174
|
+
"name": "repo-morning",
|
|
175
|
+
"steps": [
|
|
176
|
+
{
|
|
177
|
+
"id": "review",
|
|
178
|
+
"target": {
|
|
179
|
+
"type": "agent",
|
|
180
|
+
"provider": "codewith",
|
|
181
|
+
"cwd": "/path/to/repo",
|
|
182
|
+
"promptFile": "prompts/repo-morning-review.md"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
OpenLoops stores the resolved prompt text for execution and records
|
|
190
|
+
`promptSource` metadata with the absolute file path. Public CLI output from
|
|
191
|
+
`show`, `list`, `workflows list`, `workflows show`, `workflows validate`,
|
|
192
|
+
`workflows create`, and `templates render` redacts prompt bodies by default
|
|
193
|
+
while keeping `promptSource` visible. Use
|
|
194
|
+
`~/.hasna/loops/prompts/<stable-name>.md` as the default prompt store for local
|
|
195
|
+
production loops.
|
|
196
|
+
|
|
197
|
+
Reusable custom templates cannot contain `promptFile`. Keep prompt files in
|
|
198
|
+
direct workflow JSON or agent loop creation; use template variables only for
|
|
199
|
+
non-secret routing/configuration data.
|
|
200
|
+
|
|
151
201
|
## Workflows
|
|
152
202
|
|
|
153
203
|
Create a workflow JSON file:
|
|
@@ -195,6 +245,11 @@ loops workflows recover <workflow-run-id>
|
|
|
195
245
|
loops create workflow repo-morning-loop --workflow repo-morning --cron "0 8 * * *"
|
|
196
246
|
```
|
|
197
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
|
+
|
|
198
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.
|
|
199
254
|
|
|
200
255
|
For command steps, `command` is the executable when `shell` is not true. Put flags in `args`:
|
|
@@ -260,6 +315,30 @@ Custom reusable workflow templates live under the OpenLoops app data directory:
|
|
|
260
315
|
showing, and rendering templates never executes workflow steps or mutates the
|
|
261
316
|
registry.
|
|
262
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
|
+
|
|
263
342
|
```json
|
|
264
343
|
{
|
|
265
344
|
"id": "custom-report",
|
|
@@ -268,8 +347,7 @@ registry.
|
|
|
268
347
|
"kind": "workflow",
|
|
269
348
|
"variables": [
|
|
270
349
|
{ "name": "objective", "required": true, "description": "Report objective." },
|
|
271
|
-
{ "name": "projectPath", "required": true, "description": "Working directory." }
|
|
272
|
-
{ "name": "timeoutMs", "default": "300000", "type": "number" }
|
|
350
|
+
{ "name": "projectPath", "required": true, "description": "Working directory." }
|
|
273
351
|
],
|
|
274
352
|
"workflow": {
|
|
275
353
|
"name": "custom-report-${objective}",
|
|
@@ -284,9 +362,9 @@ registry.
|
|
|
284
362
|
"configIsolation": "safe",
|
|
285
363
|
"permissionMode": "bypass",
|
|
286
364
|
"sandbox": "workspace-write",
|
|
287
|
-
"timeoutMs":
|
|
365
|
+
"timeoutMs": null
|
|
288
366
|
},
|
|
289
|
-
"timeoutMs":
|
|
367
|
+
"timeoutMs": null
|
|
290
368
|
}
|
|
291
369
|
]
|
|
292
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