@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.
@@ -314,6 +314,8 @@ function updateReadyFlags(nodes, planStatus) {
314
314
  }
315
315
 
316
316
  // src/lib/workflow-spec.ts
317
+ import { readFileSync } from "fs";
318
+ import { isAbsolute, resolve } from "path";
317
319
  function assertObject(value, label) {
318
320
  if (!value || typeof value !== "object" || Array.isArray(value))
319
321
  throw new Error(`${label} must be an object`);
@@ -329,6 +331,15 @@ function optionalPositiveInteger(value, label) {
329
331
  throw new Error(`${label} must be a positive integer`);
330
332
  return value;
331
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
+ }
332
343
  function optionalBoolean(value, label) {
333
344
  if (value === undefined)
334
345
  return;
@@ -359,6 +370,38 @@ function optionalAccountRef(value, label) {
359
370
  tool: typeof value.tool === "string" ? value.tool.trim() : undefined
360
371
  };
361
372
  }
373
+ function promptFilePath(rawPath, opts) {
374
+ return isAbsolute(rawPath) ? resolve(rawPath) : resolve(opts.baseDir ?? process.cwd(), rawPath);
375
+ }
376
+ function readPromptFile(rawPath, label, opts) {
377
+ assertString(rawPath, `${label}.promptFile`);
378
+ const path = promptFilePath(rawPath.trim(), opts);
379
+ let prompt;
380
+ try {
381
+ prompt = readFileSync(path, "utf8");
382
+ } catch (error) {
383
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
384
+ throw new Error(`${label}.promptFile could not be read${code ? `: ${code}` : ""}`);
385
+ }
386
+ if (prompt.trim() === "")
387
+ throw new Error(`${label}.promptFile must contain a non-empty prompt`);
388
+ return { prompt, promptSource: { type: "file", path } };
389
+ }
390
+ function matchingPromptSource(value, prompt, opts) {
391
+ if (!value || typeof value !== "object" || Array.isArray(value))
392
+ return;
393
+ if (value.type !== "file")
394
+ return;
395
+ const rawPath = value.path;
396
+ if (typeof rawPath !== "string" || rawPath.trim() === "")
397
+ return;
398
+ const path = promptFilePath(rawPath.trim(), opts);
399
+ try {
400
+ return readFileSync(path, "utf8") === prompt ? { type: "file", path } : undefined;
401
+ } catch {
402
+ return;
403
+ }
404
+ }
362
405
  function normalizeGoalSpec(value, label = "goal") {
363
406
  if (value === undefined)
364
407
  return;
@@ -381,11 +424,11 @@ function normalizeGoalSpec(value, label = "goal") {
381
424
  autoExecute
382
425
  };
383
426
  }
384
- function validateTarget(value, label) {
427
+ function validateTarget(value, label, opts) {
385
428
  assertObject(value, label);
386
429
  if (value.type === "command") {
387
430
  assertString(value.command, `${label}.command`);
388
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
431
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
389
432
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
390
433
  if (value.shell !== true && /\s/.test(value.command.trim())) {
391
434
  throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
@@ -394,11 +437,17 @@ function validateTarget(value, label) {
394
437
  }
395
438
  if (value.type === "agent") {
396
439
  assertString(value.provider, `${label}.provider`);
397
- assertString(value.prompt, `${label}.prompt`);
440
+ const hasPrompt = typeof value.prompt === "string" && value.prompt.trim() !== "";
441
+ const hasPromptFile = typeof value.promptFile === "string" && value.promptFile.trim() !== "";
442
+ if (hasPrompt && hasPromptFile)
443
+ throw new Error(`${label} must use either prompt or promptFile, not both`);
444
+ if (!hasPrompt && !hasPromptFile)
445
+ throw new Error(`${label}.prompt must be a non-empty string or ${label}.promptFile must be set`);
446
+ const promptFields = hasPrompt ? { prompt: value.prompt, promptSource: matchingPromptSource(value.promptSource, value.prompt, opts) } : readPromptFile(value.promptFile, label, opts);
398
447
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
399
448
  if (!providers.includes(value.provider))
400
449
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
401
- optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
450
+ optionalTimeoutMs(value.timeoutMs, `${label}.timeoutMs`);
402
451
  optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
403
452
  if (value.authProfile !== undefined) {
404
453
  assertString(value.authProfile, `${label}.authProfile`);
@@ -495,11 +544,14 @@ function validateTarget(value, label) {
495
544
  if (value.routing.eventSource !== undefined)
496
545
  assertString(value.routing.eventSource, `${label}.routing.eventSource`);
497
546
  }
498
- return value;
547
+ const target = { ...value };
548
+ delete target.promptFile;
549
+ delete target.promptSource;
550
+ return { ...target, ...promptFields };
499
551
  }
500
552
  throw new Error(`${label}.type must be command or agent`);
501
553
  }
502
- function normalizeCreateWorkflowInput(input) {
554
+ function normalizeCreateWorkflowInput(input, opts = {}) {
503
555
  assertString(input.name, "workflow.name");
504
556
  const goal = normalizeGoalSpec(input.goal, "goal");
505
557
  if (!Array.isArray(input.steps) || input.steps.length === 0)
@@ -515,10 +567,10 @@ function normalizeCreateWorkflowInput(input) {
515
567
  ...step,
516
568
  id: step.id,
517
569
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
518
- target: validateTarget(step.target, `workflow.steps[${index}].target`),
570
+ target: validateTarget(step.target, `workflow.steps[${index}].target`, opts),
519
571
  dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
520
572
  continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
521
- timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
573
+ timeoutMs: optionalTimeoutMs(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
522
574
  account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
523
575
  };
524
576
  });
@@ -558,7 +610,7 @@ function workflowExecutionOrder(workflow) {
558
610
  visit(step);
559
611
  return order;
560
612
  }
561
- function workflowBodyFromJson(value, fallbackName) {
613
+ function workflowBodyFromJson(value, fallbackName, opts = {}) {
562
614
  assertObject(value, "workflow file");
563
615
  const rawName = fallbackName ?? value.name;
564
616
  assertString(rawName, "workflow.name");
@@ -570,7 +622,7 @@ function workflowBodyFromJson(value, fallbackName) {
570
622
  goal: normalizeGoalSpec(value.goal, "goal"),
571
623
  version: typeof value.version === "number" ? value.version : undefined,
572
624
  steps: value.steps
573
- });
625
+ }, opts);
574
626
  }
575
627
 
576
628
  // src/lib/run-artifacts.ts
@@ -1207,13 +1259,17 @@ class Store {
1207
1259
  }
1208
1260
  createLoop(input, from = new Date) {
1209
1261
  const now = nowIso();
1262
+ const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1263
+ name: "loop-target-validation",
1264
+ steps: [{ id: "target", target: input.target }]
1265
+ }).steps[0].target;
1210
1266
  const loop = {
1211
1267
  id: genId(),
1212
1268
  name: input.name,
1213
1269
  description: input.description,
1214
1270
  status: "active",
1215
1271
  schedule: input.schedule,
1216
- target: input.target,
1272
+ target,
1217
1273
  goal: input.goal,
1218
1274
  machine: input.machine,
1219
1275
  nextRunAt: initialNextRun(input.schedule, from),
@@ -1260,6 +1316,17 @@ class Store {
1260
1316
  const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
1261
1317
  return row ? rowToLoop(row) : undefined;
1262
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
+ }
1263
1330
  requireLoop(idOrName) {
1264
1331
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
1265
1332
  throw new Error(`loop not found: ${idOrName}`);
@@ -1334,6 +1401,130 @@ class Store {
1334
1401
  throw new Error(`loop not found after update: ${id}`);
1335
1402
  return after;
1336
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
+ }
1337
1528
  renameLoop(id, name, opts = {}) {
1338
1529
  const current = this.getLoop(id);
1339
1530
  if (!current)
@@ -3443,7 +3634,7 @@ function commandSpec(target) {
3443
3634
  cwd: commandTarget.cwd,
3444
3635
  shell: commandTarget.shell,
3445
3636
  env: commandTarget.env,
3446
- timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3637
+ timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
3447
3638
  idleTimeoutMs: commandTarget.idleTimeoutMs,
3448
3639
  account: commandTarget.account,
3449
3640
  accountTool: commandTarget.account?.tool
@@ -3454,7 +3645,7 @@ function commandSpec(target) {
3454
3645
  command: providerCommand(agentTarget.provider),
3455
3646
  args: agentArgs(agentTarget),
3456
3647
  cwd: agentTarget.cwd,
3457
- timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
3648
+ timeoutMs: agentTarget.timeoutMs ?? null,
3458
3649
  idleTimeoutMs: agentTarget.idleTimeoutMs,
3459
3650
  account: agentTarget.account,
3460
3651
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
@@ -3642,12 +3833,12 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3642
3833
  if (opts.signal?.aborted)
3643
3834
  abortHandler();
3644
3835
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3645
- const timer = setTimeout(() => {
3836
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
3646
3837
  timedOut = true;
3647
3838
  if (child.pid)
3648
3839
  killProcessGroup(child.pid);
3649
- }, spec.timeoutMs);
3650
- timer.unref();
3840
+ }, spec.timeoutMs) : undefined;
3841
+ timer?.unref();
3651
3842
  let idleTimer;
3652
3843
  const resetIdleTimer = () => {
3653
3844
  if (!spec.idleTimeoutMs)
@@ -3679,7 +3870,8 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
3679
3870
  } catch (err) {
3680
3871
  error = err instanceof Error ? err.message : String(err);
3681
3872
  } finally {
3682
- clearTimeout(timer);
3873
+ if (timer)
3874
+ clearTimeout(timer);
3683
3875
  if (idleTimer)
3684
3876
  clearTimeout(idleTimer);
3685
3877
  opts.signal?.removeEventListener("abort", abortHandler);
@@ -3821,12 +4013,12 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3821
4013
  if (opts.signal?.aborted)
3822
4014
  abortHandler();
3823
4015
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
3824
- const timer = setTimeout(() => {
4016
+ const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
3825
4017
  timedOut = true;
3826
4018
  if (child.pid)
3827
4019
  killProcessGroup(child.pid);
3828
- }, spec.timeoutMs);
3829
- timer.unref();
4020
+ }, spec.timeoutMs) : undefined;
4021
+ timer?.unref();
3830
4022
  let idleTimer;
3831
4023
  const resetIdleTimer = () => {
3832
4024
  if (!spec.idleTimeoutMs)
@@ -3858,7 +4050,8 @@ async function executeTarget(target, metadata = {}, opts = {}) {
3858
4050
  } catch (err) {
3859
4051
  error = err instanceof Error ? err.message : String(err);
3860
4052
  } finally {
3861
- clearTimeout(timer);
4053
+ if (timer)
4054
+ clearTimeout(timer);
3862
4055
  if (idleTimer)
3863
4056
  clearTimeout(idleTimer);
3864
4057
  opts.signal?.removeEventListener("abort", abortHandler);
@@ -4283,7 +4476,7 @@ ${result.stderr}`);
4283
4476
  // src/lib/workflow-runner.ts
4284
4477
  function targetWithStepAccount(step) {
4285
4478
  const account = step.account ?? step.target.account;
4286
- const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
4479
+ const timeoutMs = step.timeoutMs !== undefined ? step.timeoutMs : step.target.timeoutMs;
4287
4480
  if (!account && timeoutMs === step.target.timeoutMs)
4288
4481
  return step.target;
4289
4482
  return { ...step.target, account, timeoutMs };
@@ -4579,7 +4772,8 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4579
4772
  })
4580
4773
  });
4581
4774
  }
4582
- 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;
4583
4777
  let workflowTimedOut = false;
4584
4778
  const externalAbort = () => controller?.abort();
4585
4779
  if (controller && opts.signal?.aborted)
@@ -4589,13 +4783,13 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4589
4783
  const timer = controller ? setTimeout(() => {
4590
4784
  workflowTimedOut = true;
4591
4785
  controller.abort();
4592
- }, loop.target.timeoutMs) : undefined;
4786
+ }, workflowTimeoutMs) : undefined;
4593
4787
  timer?.unref();
4594
4788
  try {
4595
4789
  return await executeWorkflow(store, workflow, {
4596
4790
  ...opts,
4597
4791
  signal: controller?.signal ?? opts.signal,
4598
- 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,
4599
4793
  loop,
4600
4794
  loopRun: run,
4601
4795
  scheduledFor: run.scheduledFor,
@@ -4915,13 +5109,13 @@ async function tick(deps) {
4915
5109
  }
4916
5110
 
4917
5111
  // src/daemon/control.ts
4918
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
5112
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4919
5113
  import { hostname } from "os";
4920
5114
  import { dirname as dirname2 } from "path";
4921
5115
 
4922
5116
  // src/daemon/loop.ts
4923
5117
  function realSleep(ms) {
4924
- return new Promise((resolve) => setTimeout(resolve, ms));
5118
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
4925
5119
  }
4926
5120
  async function runLoop(opts) {
4927
5121
  const sleep = opts.sleep ?? realSleep;
@@ -4946,7 +5140,7 @@ function readPid(path = pidFilePath()) {
4946
5140
  if (!existsSync3(path))
4947
5141
  return;
4948
5142
  try {
4949
- const pid = Number(readFileSync(path, "utf8").trim());
5143
+ const pid = Number(readFileSync2(path, "utf8").trim());
4950
5144
  return Number.isInteger(pid) && pid > 0 ? pid : undefined;
4951
5145
  } catch {
4952
5146
  return;
@@ -5299,7 +5493,7 @@ function enableStartup(result) {
5299
5493
  // package.json
5300
5494
  var package_default = {
5301
5495
  name: "@hasna/loops",
5302
- version: "0.3.53",
5496
+ version: "0.3.55",
5303
5497
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5304
5498
  type: "module",
5305
5499
  main: "dist/index.js",