@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.
@@ -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
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
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
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
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: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].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 ?? DEFAULT_TIMEOUT_MS,
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 ?? DEFAULT_TIMEOUT_MS,
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.unref();
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
- clearTimeout(timer);
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.unref();
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
- clearTimeout(timer);
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 ?? step.target.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 controller = loop.target.timeoutMs ? new AbortController : undefined;
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
- }, loop.target.timeoutMs) : undefined;
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 ${loop.target.timeoutMs}ms` : undefined,
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.54",
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",