@hasna/loops 0.3.14 → 0.3.16

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.
@@ -18,6 +18,7 @@ export interface DaemonStatus extends DaemonProcessState {
18
18
  paused: number;
19
19
  stopped: number;
20
20
  expired: number;
21
+ archived: number;
21
22
  };
22
23
  runs: {
23
24
  total: number;
@@ -328,6 +328,17 @@ function optionalPositiveInteger(value, label) {
328
328
  throw new Error(`${label} must be a positive integer`);
329
329
  return value;
330
330
  }
331
+ function optionalStringArray(value, label) {
332
+ if (value === undefined)
333
+ return;
334
+ if (!Array.isArray(value))
335
+ throw new Error(`${label} must be an array`);
336
+ const values = value.map((entry, index) => {
337
+ assertString(entry, `${label}[${index}]`);
338
+ return entry.trim();
339
+ }).filter(Boolean);
340
+ return values.length ? values : undefined;
341
+ }
331
342
  function normalizeGoalSpec(value, label = "goal") {
332
343
  if (value === undefined)
333
344
  return;
@@ -399,6 +410,14 @@ function validateTarget(value, label) {
399
410
  throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
400
411
  }
401
412
  }
413
+ if (value.allowlist !== undefined) {
414
+ assertObject(value.allowlist, `${label}.allowlist`);
415
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
416
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
417
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
418
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
419
+ }
420
+ }
402
421
  return value;
403
422
  }
404
423
  throw new Error(`${label}.type must be command or agent`);
@@ -482,6 +501,8 @@ function rowToLoop(row) {
482
501
  name: row.name,
483
502
  description: row.description ?? undefined,
484
503
  status: row.status,
504
+ archivedAt: row.archived_at ?? undefined,
505
+ archivedFromStatus: row.archived_from_status ? row.archived_from_status : undefined,
485
506
  schedule: JSON.parse(row.schedule_json),
486
507
  target: JSON.parse(row.target_json),
487
508
  goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
@@ -691,6 +712,8 @@ class Store {
691
712
  name TEXT NOT NULL,
692
713
  description TEXT,
693
714
  status TEXT NOT NULL,
715
+ archived_at TEXT,
716
+ archived_from_status TEXT,
694
717
  schedule_json TEXT NOT NULL,
695
718
  target_json TEXT NOT NULL,
696
719
  goal_json TEXT,
@@ -891,6 +914,8 @@ class Store {
891
914
  `);
892
915
  this.addColumnIfMissing("loops", "machine_json", "TEXT");
893
916
  this.addColumnIfMissing("loops", "goal_json", "TEXT");
917
+ this.addColumnIfMissing("loops", "archived_at", "TEXT");
918
+ this.addColumnIfMissing("loops", "archived_from_status", "TEXT");
894
919
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
895
920
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
896
921
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
@@ -899,6 +924,7 @@ class Store {
899
924
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
900
925
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
901
926
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
927
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
902
928
  }
903
929
  addColumnIfMissing(table, column, definition) {
904
930
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -975,12 +1001,26 @@ class Store {
975
1001
  }
976
1002
  listLoops(opts = {}) {
977
1003
  const limit = opts.limit ?? 200;
978
- const rows = opts.status ? this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1004
+ let rows;
1005
+ if (opts.status && opts.archived) {
1006
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NOT NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1007
+ } else if (opts.status && opts.includeArchived) {
1008
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1009
+ } else if (opts.status) {
1010
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? AND archived_at IS NULL ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1011
+ } else if (opts.archived) {
1012
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ?").all(limit);
1013
+ } else if (opts.includeArchived) {
1014
+ rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1015
+ } else {
1016
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1017
+ }
979
1018
  return rows.map(rowToLoop);
980
1019
  }
981
1020
  dueLoops(now) {
982
1021
  const rows = this.db.query(`SELECT * FROM loops
983
1022
  WHERE status = 'active'
1023
+ AND archived_at IS NULL
984
1024
  AND next_run_at IS NOT NULL
985
1025
  AND next_run_at <= ?
986
1026
  ORDER BY next_run_at ASC`).all(now.toISOString());
@@ -1012,6 +1052,44 @@ class Store {
1012
1052
  throw new Error(`loop not found after update: ${id}`);
1013
1053
  return after;
1014
1054
  }
1055
+ archiveLoop(idOrName) {
1056
+ const loop = this.requireLoop(idOrName);
1057
+ if (loop.archivedAt)
1058
+ return loop;
1059
+ const updated = nowIso();
1060
+ const archivedStatus = loop.status === "active" ? "paused" : loop.status;
1061
+ this.db.query(`UPDATE loops
1062
+ SET status=$status, archived_at=$archivedAt, archived_from_status=$archivedFromStatus, updated_at=$updated
1063
+ WHERE id=$id`).run({
1064
+ $id: loop.id,
1065
+ $status: archivedStatus,
1066
+ $archivedAt: updated,
1067
+ $archivedFromStatus: loop.status,
1068
+ $updated: updated
1069
+ });
1070
+ const archived = this.getLoop(loop.id);
1071
+ if (!archived)
1072
+ throw new Error(`loop not found after archive: ${loop.id}`);
1073
+ return archived;
1074
+ }
1075
+ unarchiveLoop(idOrName) {
1076
+ const loop = this.requireLoop(idOrName);
1077
+ if (!loop.archivedAt)
1078
+ return loop;
1079
+ const updated = nowIso();
1080
+ const restoredStatus = loop.archivedFromStatus ?? loop.status;
1081
+ this.db.query(`UPDATE loops
1082
+ SET status=$status, archived_at=NULL, archived_from_status=NULL, updated_at=$updated
1083
+ WHERE id=$id`).run({
1084
+ $id: loop.id,
1085
+ $status: restoredStatus,
1086
+ $updated: updated
1087
+ });
1088
+ const unarchived = this.getLoop(loop.id);
1089
+ if (!unarchived)
1090
+ throw new Error(`loop not found after unarchive: ${loop.id}`);
1091
+ return unarchived;
1092
+ }
1015
1093
  deleteLoop(idOrName) {
1016
1094
  const loop = this.requireLoop(idOrName);
1017
1095
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
@@ -1786,10 +1864,16 @@ class Store {
1786
1864
  }
1787
1865
  claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1788
1866
  const startedAt = now.toISOString();
1789
- const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1790
1867
  this.db.exec("BEGIN IMMEDIATE");
1791
1868
  try {
1792
1869
  this.assertDaemonLeaseFence(opts, startedAt);
1870
+ const currentLoop = this.getLoop(loop.id);
1871
+ if (!currentLoop || currentLoop.archivedAt) {
1872
+ this.db.exec("COMMIT");
1873
+ return;
1874
+ }
1875
+ loop = currentLoop;
1876
+ const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1793
1877
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1794
1878
  if (existing) {
1795
1879
  if (existing.status === "running") {
@@ -2007,7 +2091,7 @@ class Store {
2007
2091
  return recovered;
2008
2092
  }
2009
2093
  expireLoops(now = new Date, opts = {}) {
2010
- const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
2094
+ const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND archived_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
2011
2095
  const expired = [];
2012
2096
  for (const row of rows) {
2013
2097
  const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
@@ -2016,8 +2100,21 @@ class Store {
2016
2100
  }
2017
2101
  return expired;
2018
2102
  }
2019
- countLoops(status) {
2020
- const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loops").get();
2103
+ countLoops(status, opts = {}) {
2104
+ let row;
2105
+ if (status && opts.archived) {
2106
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NOT NULL").get(status);
2107
+ } else if (status && opts.includeArchived) {
2108
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status);
2109
+ } else if (status) {
2110
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NULL").get(status);
2111
+ } else if (opts.archived) {
2112
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NOT NULL").get();
2113
+ } else if (opts.includeArchived) {
2114
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops").get();
2115
+ } else {
2116
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NULL").get();
2117
+ }
2021
2118
  return row?.count ?? 0;
2022
2119
  }
2023
2120
  countRuns(status) {
@@ -2387,6 +2484,16 @@ function metadataEnv(metadata) {
2387
2484
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2388
2485
  return env;
2389
2486
  }
2487
+ function allowlistEnv(allowlist) {
2488
+ const env = {};
2489
+ if (allowlist?.tools?.length)
2490
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2491
+ if (allowlist?.commands?.length)
2492
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2493
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2494
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2495
+ return env;
2496
+ }
2390
2497
  function providerCommand(provider) {
2391
2498
  switch (provider) {
2392
2499
  case "claude":
@@ -2594,7 +2701,8 @@ function commandSpec(target) {
2594
2701
  account: agentTarget.account,
2595
2702
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2596
2703
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2597
- stdin: agentTarget.prompt
2704
+ stdin: agentTarget.prompt,
2705
+ allowlist: agentTarget.allowlist
2598
2706
  };
2599
2707
  }
2600
2708
  function executionEnv(spec, metadata, opts) {
@@ -2606,6 +2714,7 @@ function executionEnv(spec, metadata, opts) {
2606
2714
  Object.assign(env, accountEnv);
2607
2715
  }
2608
2716
  Object.assign(env, spec.env ?? {});
2717
+ Object.assign(env, allowlistEnv(spec.allowlist));
2609
2718
  env.PATH = normalizeExecutionPath(env);
2610
2719
  Object.assign(env, metadataEnv(metadata));
2611
2720
  return env;
@@ -2644,6 +2753,9 @@ function remoteBootstrapLines(spec, metadata) {
2644
2753
  continue;
2645
2754
  lines.push(`export ${key}=${shellQuote(value)}`);
2646
2755
  }
2756
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2757
+ lines.push(`export ${key}=${shellQuote(value)}`);
2758
+ }
2647
2759
  return lines;
2648
2760
  }
2649
2761
  function remoteScript(spec, metadata) {
@@ -3597,12 +3709,16 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
3597
3709
 
3598
3710
  // src/lib/scheduler.ts
3599
3711
  function manualRunScheduledFor(loop, now = new Date) {
3712
+ if (loop.archivedAt)
3713
+ return now.toISOString();
3600
3714
  if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
3601
3715
  return loop.retryScheduledFor ?? loop.nextRunAt;
3602
3716
  }
3603
3717
  return now.toISOString();
3604
3718
  }
3605
3719
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
3720
+ if (loop.archivedAt)
3721
+ return false;
3606
3722
  if (loop.status !== "active")
3607
3723
  return false;
3608
3724
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
@@ -3610,6 +3726,8 @@ function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
3610
3726
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
3611
3727
  }
3612
3728
  function manualRunSource(loop, scheduledFor, now = new Date) {
3729
+ if (loop.archivedAt)
3730
+ return "ad_hoc";
3613
3731
  if (loop.status !== "active")
3614
3732
  return "ad_hoc";
3615
3733
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
@@ -3628,7 +3746,7 @@ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
3628
3746
  if (run.status === "running")
3629
3747
  return;
3630
3748
  const current = store.getLoop(loop.id);
3631
- if (!current || current.status !== "active")
3749
+ if (!current || current.status !== "active" || current.archivedAt)
3632
3750
  return;
3633
3751
  if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
3634
3752
  return;
@@ -3963,7 +4081,8 @@ function daemonStatus(store, path = pidFilePath()) {
3963
4081
  active: store.countLoops("active"),
3964
4082
  paused: store.countLoops("paused"),
3965
4083
  stopped: store.countLoops("stopped"),
3966
- expired: store.countLoops("expired")
4084
+ expired: store.countLoops("expired"),
4085
+ archived: store.countLoops(undefined, { archived: true })
3967
4086
  },
3968
4087
  runs: {
3969
4088
  total: store.countRuns(),
@@ -4276,7 +4395,7 @@ function enableStartup(result) {
4276
4395
  // package.json
4277
4396
  var package_default = {
4278
4397
  name: "@hasna/loops",
4279
- version: "0.3.14",
4398
+ version: "0.3.16",
4280
4399
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4281
4400
  type: "module",
4282
4401
  main: "dist/index.js",
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { executeWorkflow, executeLoopTarget, preflightWorkflow } from "./lib/wor
10
10
  export { workflowExecutionOrder, workflowBodyFromJson } from "./lib/workflow-spec.js";
11
11
  export { EVENT_WORKER_VERIFIER_TEMPLATE_ID, TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID, getLoopTemplate, listLoopTemplates, renderEventWorkerVerifierWorkflow, renderLoopTemplate, renderTodosTaskWorkerVerifierWorkflow, } from "./lib/templates.js";
12
12
  export { runDoctor } from "./lib/doctor.js";
13
+ export { buildHealthReport, classifyRunFailure, expectationForLoop } from "./lib/health.js";
13
14
  export { runGoal } from "./lib/goal/runner.js";
14
15
  export { resolveGoalModel } from "./lib/goal/model-factory.js";
15
16
  export { isTerminal as isGoalTerminal, readyNodeKeys, rollupSummary } from "./lib/goal/status.js";