@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/daemon/index.js
CHANGED
|
@@ -331,6 +331,15 @@ function optionalPositiveInteger(value, label) {
|
|
|
331
331
|
throw new Error(`${label} must be a positive integer`);
|
|
332
332
|
return value;
|
|
333
333
|
}
|
|
334
|
+
function optionalTimeoutMs(value, label) {
|
|
335
|
+
if (value === undefined)
|
|
336
|
+
return;
|
|
337
|
+
if (value === null)
|
|
338
|
+
return null;
|
|
339
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
340
|
+
throw new Error(`${label} must be a positive integer or null for unlimited`);
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
334
343
|
function optionalBoolean(value, label) {
|
|
335
344
|
if (value === undefined)
|
|
336
345
|
return;
|
|
@@ -419,7 +428,7 @@ function validateTarget(value, label, opts) {
|
|
|
419
428
|
assertObject(value, label);
|
|
420
429
|
if (value.type === "command") {
|
|
421
430
|
assertString(value.command, `${label}.command`);
|
|
422
|
-
|
|
431
|
+
optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
|
|
423
432
|
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
424
433
|
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
425
434
|
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
@@ -438,7 +447,7 @@ function validateTarget(value, label, opts) {
|
|
|
438
447
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
439
448
|
if (!providers.includes(value.provider))
|
|
440
449
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
441
|
-
|
|
450
|
+
optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
|
|
442
451
|
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
443
452
|
if (value.authProfile !== undefined) {
|
|
444
453
|
assertString(value.authProfile, `${label}.authProfile`);
|
|
@@ -561,7 +570,7 @@ function normalizeCreateWorkflowInput(input, opts = {}) {
|
|
|
561
570
|
target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
|
|
562
571
|
dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
|
|
563
572
|
continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
|
|
564
|
-
timeoutMs:
|
|
573
|
+
timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
|
|
565
574
|
account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
|
|
566
575
|
};
|
|
567
576
|
});
|
|
@@ -1307,6 +1316,17 @@ class Store {
|
|
|
1307
1316
|
const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
|
|
1308
1317
|
return row ? rowToLoop(row) : undefined;
|
|
1309
1318
|
}
|
|
1319
|
+
requireUniqueLoop(idOrName) {
|
|
1320
|
+
const byId = this.getLoop(idOrName);
|
|
1321
|
+
if (byId)
|
|
1322
|
+
return byId;
|
|
1323
|
+
const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
|
|
1324
|
+
if (rows.length === 0)
|
|
1325
|
+
throw new Error(`loop not found: ${idOrName}`);
|
|
1326
|
+
if (rows.length > 1)
|
|
1327
|
+
throw new Error(`ambiguous loop name: ${idOrName}; use a loop id`);
|
|
1328
|
+
return rowToLoop(rows[0]);
|
|
1329
|
+
}
|
|
1310
1330
|
requireLoop(idOrName) {
|
|
1311
1331
|
return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
|
|
1312
1332
|
throw new Error(`loop not found: ${idOrName}`);
|
|
@@ -1381,6 +1401,130 @@ class Store {
|
|
|
1381
1401
|
throw new Error(`loop not found after update: ${id}`);
|
|
1382
1402
|
return after;
|
|
1383
1403
|
}
|
|
1404
|
+
activeLoopReferenceCount(workflowId) {
|
|
1405
|
+
const rows = this.db.query("SELECT target_json FROM loops WHERE archived_at IS NULL").all();
|
|
1406
|
+
let count = 0;
|
|
1407
|
+
for (const row of rows) {
|
|
1408
|
+
try {
|
|
1409
|
+
const target = JSON.parse(row.target_json);
|
|
1410
|
+
if (target.type === "workflow" && target.workflowId === workflowId)
|
|
1411
|
+
count += 1;
|
|
1412
|
+
} catch {}
|
|
1413
|
+
}
|
|
1414
|
+
return count;
|
|
1415
|
+
}
|
|
1416
|
+
archiveWorkflowIfUnreferenced(workflowId, updated) {
|
|
1417
|
+
if (this.activeLoopReferenceCount(workflowId) > 0)
|
|
1418
|
+
return;
|
|
1419
|
+
const workflow = this.getWorkflow(workflowId);
|
|
1420
|
+
if (!workflow || workflow.status !== "active")
|
|
1421
|
+
return;
|
|
1422
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(updated, workflowId);
|
|
1423
|
+
if (res.changes !== 1)
|
|
1424
|
+
return;
|
|
1425
|
+
return this.getWorkflow(workflowId);
|
|
1426
|
+
}
|
|
1427
|
+
retargetWorkflowLoop(idOrName, workflowId, opts = {}) {
|
|
1428
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
1429
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1430
|
+
try {
|
|
1431
|
+
const current = this.requireLoop(idOrName);
|
|
1432
|
+
if (current.target.type !== "workflow")
|
|
1433
|
+
throw new Error(`loop is not a workflow loop: ${idOrName}`);
|
|
1434
|
+
if (this.hasRunningRun(current.id))
|
|
1435
|
+
throw new Error(`refusing to retarget running loop: ${current.id}`);
|
|
1436
|
+
const workflow = this.requireWorkflow(workflowId);
|
|
1437
|
+
const target = { ...current.target, workflowId: workflow.id };
|
|
1438
|
+
if (opts.workflowTimeoutMs !== undefined)
|
|
1439
|
+
target.timeoutMs = opts.workflowTimeoutMs;
|
|
1440
|
+
const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
|
|
1441
|
+
WHERE id=$id
|
|
1442
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1443
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1444
|
+
))`).run({
|
|
1445
|
+
$id: current.id,
|
|
1446
|
+
$target: JSON.stringify(target),
|
|
1447
|
+
$updated: updated,
|
|
1448
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1449
|
+
$now: updated
|
|
1450
|
+
});
|
|
1451
|
+
if (res.changes !== 1)
|
|
1452
|
+
throw new Error("daemon lease lost");
|
|
1453
|
+
this.db.exec("COMMIT");
|
|
1454
|
+
const after = this.getLoop(current.id);
|
|
1455
|
+
if (!after)
|
|
1456
|
+
throw new Error(`loop not found after retarget: ${current.id}`);
|
|
1457
|
+
return after;
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
try {
|
|
1460
|
+
this.db.exec("ROLLBACK");
|
|
1461
|
+
} catch {}
|
|
1462
|
+
throw error;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
|
|
1466
|
+
const normalized = normalizeCreateWorkflowInput(workflowInput);
|
|
1467
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
1468
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1469
|
+
try {
|
|
1470
|
+
const current = this.requireUniqueLoop(idOrName);
|
|
1471
|
+
if (current.target.type !== "workflow")
|
|
1472
|
+
throw new Error(`loop is not a workflow loop: ${idOrName}`);
|
|
1473
|
+
if (this.hasRunningRun(current.id))
|
|
1474
|
+
throw new Error(`refusing to retarget running loop: ${current.id}`);
|
|
1475
|
+
const previousWorkflow = this.requireWorkflow(current.target.workflowId);
|
|
1476
|
+
const workflow = {
|
|
1477
|
+
id: genId(),
|
|
1478
|
+
name: normalized.name,
|
|
1479
|
+
description: normalized.description,
|
|
1480
|
+
version: normalized.version ?? 1,
|
|
1481
|
+
status: "active",
|
|
1482
|
+
goal: normalized.goal,
|
|
1483
|
+
steps: normalized.steps,
|
|
1484
|
+
createdAt: updated,
|
|
1485
|
+
updatedAt: updated
|
|
1486
|
+
};
|
|
1487
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
|
|
1488
|
+
VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)`).run({
|
|
1489
|
+
$id: workflow.id,
|
|
1490
|
+
$name: workflow.name,
|
|
1491
|
+
$description: workflow.description ?? null,
|
|
1492
|
+
$version: workflow.version,
|
|
1493
|
+
$status: workflow.status,
|
|
1494
|
+
$goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
|
|
1495
|
+
$steps: JSON.stringify(workflow.steps),
|
|
1496
|
+
$created: workflow.createdAt,
|
|
1497
|
+
$updated: workflow.updatedAt
|
|
1498
|
+
});
|
|
1499
|
+
const target = { ...current.target, workflowId: workflow.id };
|
|
1500
|
+
if (opts.workflowTimeoutMs !== undefined)
|
|
1501
|
+
target.timeoutMs = opts.workflowTimeoutMs;
|
|
1502
|
+
const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
|
|
1503
|
+
WHERE id=$id
|
|
1504
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1505
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1506
|
+
))`).run({
|
|
1507
|
+
$id: current.id,
|
|
1508
|
+
$target: JSON.stringify(target),
|
|
1509
|
+
$updated: updated,
|
|
1510
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1511
|
+
$now: updated
|
|
1512
|
+
});
|
|
1513
|
+
if (res.changes !== 1)
|
|
1514
|
+
throw new Error("daemon lease lost");
|
|
1515
|
+
const archivedOld = opts.archiveOld ? this.archiveWorkflowIfUnreferenced(previousWorkflow.id, updated) : undefined;
|
|
1516
|
+
this.db.exec("COMMIT");
|
|
1517
|
+
const loop = this.getLoop(current.id);
|
|
1518
|
+
if (!loop)
|
|
1519
|
+
throw new Error(`loop not found after retarget: ${current.id}`);
|
|
1520
|
+
return { loop, workflow, previousWorkflow, archivedOld };
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
try {
|
|
1523
|
+
this.db.exec("ROLLBACK");
|
|
1524
|
+
} catch {}
|
|
1525
|
+
throw error;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1384
1528
|
renameLoop(id, name, opts = {}) {
|
|
1385
1529
|
const current = this.getLoop(id);
|
|
1386
1530
|
if (!current)
|
|
@@ -3490,7 +3634,7 @@ function commandSpec(target) {
|
|
|
3490
3634
|
cwd: commandTarget.cwd,
|
|
3491
3635
|
shell: commandTarget.shell,
|
|
3492
3636
|
env: commandTarget.env,
|
|
3493
|
-
timeoutMs: commandTarget.timeoutMs
|
|
3637
|
+
timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
|
|
3494
3638
|
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
3495
3639
|
account: commandTarget.account,
|
|
3496
3640
|
accountTool: commandTarget.account?.tool
|
|
@@ -3501,7 +3645,7 @@ function commandSpec(target) {
|
|
|
3501
3645
|
command: providerCommand(agentTarget.provider),
|
|
3502
3646
|
args: agentArgs(agentTarget),
|
|
3503
3647
|
cwd: agentTarget.cwd,
|
|
3504
|
-
timeoutMs: agentTarget.timeoutMs ??
|
|
3648
|
+
timeoutMs: agentTarget.timeoutMs ?? null,
|
|
3505
3649
|
idleTimeoutMs: agentTarget.idleTimeoutMs,
|
|
3506
3650
|
account: agentTarget.account,
|
|
3507
3651
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
@@ -3689,12 +3833,12 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3689
3833
|
if (opts.signal?.aborted)
|
|
3690
3834
|
abortHandler();
|
|
3691
3835
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3692
|
-
const timer = setTimeout(() => {
|
|
3836
|
+
const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
|
|
3693
3837
|
timedOut = true;
|
|
3694
3838
|
if (child.pid)
|
|
3695
3839
|
killProcessGroup(child.pid);
|
|
3696
|
-
}, spec.timeoutMs);
|
|
3697
|
-
timer
|
|
3840
|
+
}, spec.timeoutMs) : undefined;
|
|
3841
|
+
timer?.unref();
|
|
3698
3842
|
let idleTimer;
|
|
3699
3843
|
const resetIdleTimer = () => {
|
|
3700
3844
|
if (!spec.idleTimeoutMs)
|
|
@@ -3726,7 +3870,8 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3726
3870
|
} catch (err) {
|
|
3727
3871
|
error = err instanceof Error ? err.message : String(err);
|
|
3728
3872
|
} finally {
|
|
3729
|
-
|
|
3873
|
+
if (timer)
|
|
3874
|
+
clearTimeout(timer);
|
|
3730
3875
|
if (idleTimer)
|
|
3731
3876
|
clearTimeout(idleTimer);
|
|
3732
3877
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
@@ -3868,12 +4013,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3868
4013
|
if (opts.signal?.aborted)
|
|
3869
4014
|
abortHandler();
|
|
3870
4015
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3871
|
-
const timer = setTimeout(() => {
|
|
4016
|
+
const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
|
|
3872
4017
|
timedOut = true;
|
|
3873
4018
|
if (child.pid)
|
|
3874
4019
|
killProcessGroup(child.pid);
|
|
3875
|
-
}, spec.timeoutMs);
|
|
3876
|
-
timer
|
|
4020
|
+
}, spec.timeoutMs) : undefined;
|
|
4021
|
+
timer?.unref();
|
|
3877
4022
|
let idleTimer;
|
|
3878
4023
|
const resetIdleTimer = () => {
|
|
3879
4024
|
if (!spec.idleTimeoutMs)
|
|
@@ -3905,7 +4050,8 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3905
4050
|
} catch (err) {
|
|
3906
4051
|
error = err instanceof Error ? err.message : String(err);
|
|
3907
4052
|
} finally {
|
|
3908
|
-
|
|
4053
|
+
if (timer)
|
|
4054
|
+
clearTimeout(timer);
|
|
3909
4055
|
if (idleTimer)
|
|
3910
4056
|
clearTimeout(idleTimer);
|
|
3911
4057
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
@@ -4330,7 +4476,7 @@ ${result.stderr}`);
|
|
|
4330
4476
|
// src/lib/workflow-runner.ts
|
|
4331
4477
|
function targetWithStepAccount(step) {
|
|
4332
4478
|
const account = step.account ?? step.target.account;
|
|
4333
|
-
const timeoutMs = step.timeoutMs
|
|
4479
|
+
const timeoutMs = step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs;
|
|
4334
4480
|
if (!account && timeoutMs === step.target.timeoutMs)
|
|
4335
4481
|
return step.target;
|
|
4336
4482
|
return { ...step.target, account, timeoutMs };
|
|
@@ -4626,7 +4772,8 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4626
4772
|
})
|
|
4627
4773
|
});
|
|
4628
4774
|
}
|
|
4629
|
-
const
|
|
4775
|
+
const workflowTimeoutMs = typeof loop.target.timeoutMs === "number" ? loop.target.timeoutMs : undefined;
|
|
4776
|
+
const controller = workflowTimeoutMs !== undefined ? new AbortController : undefined;
|
|
4630
4777
|
let workflowTimedOut = false;
|
|
4631
4778
|
const externalAbort = () => controller?.abort();
|
|
4632
4779
|
if (controller && opts.signal?.aborted)
|
|
@@ -4636,13 +4783,13 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
4636
4783
|
const timer = controller ? setTimeout(() => {
|
|
4637
4784
|
workflowTimedOut = true;
|
|
4638
4785
|
controller.abort();
|
|
4639
|
-
},
|
|
4786
|
+
}, workflowTimeoutMs) : undefined;
|
|
4640
4787
|
timer?.unref();
|
|
4641
4788
|
try {
|
|
4642
4789
|
return await executeWorkflow(store, workflow, {
|
|
4643
4790
|
...opts,
|
|
4644
4791
|
signal: controller?.signal ?? opts.signal,
|
|
4645
|
-
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${
|
|
4792
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${workflowTimeoutMs}ms` : undefined,
|
|
4646
4793
|
loop,
|
|
4647
4794
|
loopRun: run,
|
|
4648
4795
|
scheduledFor: run.scheduledFor,
|
|
@@ -5346,7 +5493,7 @@ function enableStartup(result) {
|
|
|
5346
5493
|
// package.json
|
|
5347
5494
|
var package_default = {
|
|
5348
5495
|
name: "@hasna/loops",
|
|
5349
|
-
version: "0.3.
|
|
5496
|
+
version: "0.3.55",
|
|
5350
5497
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5351
5498
|
type: "module",
|
|
5352
5499
|
main: "dist/index.js",
|