@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.
@@ -0,0 +1,70 @@
1
+ import type { Loop, LoopRun } from "../types.js";
2
+ import type { Store } from "./store.js";
3
+ export type RunFailureClassification = "rate_limit" | "auth" | "model_not_found" | "context_length" | "schema_response_format" | "node_init" | "timeout" | "sigsegv" | "skipped_previous_active" | "unknown";
4
+ export interface RunFailureSignal {
5
+ classification: RunFailureClassification;
6
+ fingerprint: string;
7
+ evidence: {
8
+ error?: string;
9
+ stdout?: string;
10
+ stderr?: string;
11
+ exitCode?: number;
12
+ };
13
+ }
14
+ export interface RecommendedTaskUpsert {
15
+ title: string;
16
+ description: string;
17
+ priority: "critical" | "high" | "medium" | "low";
18
+ tags: string[];
19
+ dedupeKey: string;
20
+ search: {
21
+ query: string;
22
+ };
23
+ compatibilityFallback: {
24
+ search: string[];
25
+ add: string[];
26
+ comment: string[];
27
+ };
28
+ futureNativeUpsert: {
29
+ command: string;
30
+ fields: Record<string, string | string[]>;
31
+ };
32
+ }
33
+ export interface LoopExpectationResult {
34
+ loop: Pick<Loop, "id" | "name" | "status" | "nextRunAt">;
35
+ ok: boolean;
36
+ check: {
37
+ id: "latest-run-succeeded";
38
+ status: "pass" | "fail" | "warn";
39
+ message: string;
40
+ };
41
+ latestRun?: LoopRun;
42
+ failure?: RunFailureSignal;
43
+ route: {
44
+ source: "openloops";
45
+ kind: "loop_expectation";
46
+ loopId: string;
47
+ loopName: string;
48
+ cwd?: string;
49
+ provider?: string;
50
+ };
51
+ recommendedTask?: RecommendedTaskUpsert;
52
+ }
53
+ export interface LoopsHealthReport {
54
+ ok: boolean;
55
+ generatedAt: string;
56
+ summary: {
57
+ loops: number;
58
+ healthy: number;
59
+ unhealthy: number;
60
+ warnings: number;
61
+ };
62
+ classifications: Record<RunFailureClassification, number>;
63
+ expectations: LoopExpectationResult[];
64
+ }
65
+ export declare function classifyRunFailure(run: LoopRun): RunFailureSignal | undefined;
66
+ export declare function expectationForLoop(store: Store, loop: Loop): LoopExpectationResult;
67
+ export declare function buildHealthReport(store: Store, opts?: {
68
+ includeArchived?: boolean;
69
+ limit?: number;
70
+ }): LoopsHealthReport;
@@ -74,9 +74,13 @@ export declare class Store {
74
74
  listLoops(opts?: {
75
75
  status?: LoopStatus;
76
76
  limit?: number;
77
+ archived?: boolean;
78
+ includeArchived?: boolean;
77
79
  }): Loop[];
78
80
  dueLoops(now: Date): Loop[];
79
81
  updateLoop(id: string, patch: Partial<Pick<Loop, "status" | "nextRunAt" | "retryScheduledFor" | "expiresAt">>, opts?: DaemonLeaseFence): Loop;
82
+ archiveLoop(idOrName: string): Loop;
83
+ unarchiveLoop(idOrName: string): Loop;
80
84
  deleteLoop(idOrName: string): boolean;
81
85
  createWorkflow(input: CreateWorkflowInput): WorkflowSpec;
82
86
  getWorkflow(id: string): WorkflowSpec | undefined;
@@ -159,7 +163,10 @@ export declare class Store {
159
163
  }): LoopRun[];
160
164
  recoverExpiredRunLeases(now?: Date, opts?: DaemonLeaseFence): LoopRun[];
161
165
  expireLoops(now?: Date, opts?: DaemonLeaseFence): Loop[];
162
- countLoops(status?: LoopStatus): number;
166
+ countLoops(status?: LoopStatus, opts?: {
167
+ archived?: boolean;
168
+ includeArchived?: boolean;
169
+ }): number;
163
170
  countRuns(status?: RunStatus): number;
164
171
  acquireDaemonLease(input: {
165
172
  id: string;
package/dist/lib/store.js CHANGED
@@ -326,6 +326,17 @@ function optionalPositiveInteger(value, label) {
326
326
  throw new Error(`${label} must be a positive integer`);
327
327
  return value;
328
328
  }
329
+ function optionalStringArray(value, label) {
330
+ if (value === undefined)
331
+ return;
332
+ if (!Array.isArray(value))
333
+ throw new Error(`${label} must be an array`);
334
+ const values = value.map((entry, index) => {
335
+ assertString(entry, `${label}[${index}]`);
336
+ return entry.trim();
337
+ }).filter(Boolean);
338
+ return values.length ? values : undefined;
339
+ }
329
340
  function normalizeGoalSpec(value, label = "goal") {
330
341
  if (value === undefined)
331
342
  return;
@@ -397,6 +408,14 @@ function validateTarget(value, label) {
397
408
  throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
398
409
  }
399
410
  }
411
+ if (value.allowlist !== undefined) {
412
+ assertObject(value.allowlist, `${label}.allowlist`);
413
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
414
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
415
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
416
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
417
+ }
418
+ }
400
419
  return value;
401
420
  }
402
421
  throw new Error(`${label}.type must be command or agent`);
@@ -480,6 +499,8 @@ function rowToLoop(row) {
480
499
  name: row.name,
481
500
  description: row.description ?? undefined,
482
501
  status: row.status,
502
+ archivedAt: row.archived_at ?? undefined,
503
+ archivedFromStatus: row.archived_from_status ? row.archived_from_status : undefined,
483
504
  schedule: JSON.parse(row.schedule_json),
484
505
  target: JSON.parse(row.target_json),
485
506
  goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
@@ -689,6 +710,8 @@ class Store {
689
710
  name TEXT NOT NULL,
690
711
  description TEXT,
691
712
  status TEXT NOT NULL,
713
+ archived_at TEXT,
714
+ archived_from_status TEXT,
692
715
  schedule_json TEXT NOT NULL,
693
716
  target_json TEXT NOT NULL,
694
717
  goal_json TEXT,
@@ -889,6 +912,8 @@ class Store {
889
912
  `);
890
913
  this.addColumnIfMissing("loops", "machine_json", "TEXT");
891
914
  this.addColumnIfMissing("loops", "goal_json", "TEXT");
915
+ this.addColumnIfMissing("loops", "archived_at", "TEXT");
916
+ this.addColumnIfMissing("loops", "archived_from_status", "TEXT");
892
917
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
893
918
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
894
919
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
@@ -897,6 +922,7 @@ class Store {
897
922
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
898
923
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
899
924
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
925
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
900
926
  }
901
927
  addColumnIfMissing(table, column, definition) {
902
928
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -973,12 +999,26 @@ class Store {
973
999
  }
974
1000
  listLoops(opts = {}) {
975
1001
  const limit = opts.limit ?? 200;
976
- 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);
1002
+ let rows;
1003
+ if (opts.status && opts.archived) {
1004
+ 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);
1005
+ } else if (opts.status && opts.includeArchived) {
1006
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1007
+ } else if (opts.status) {
1008
+ 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);
1009
+ } else if (opts.archived) {
1010
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ?").all(limit);
1011
+ } else if (opts.includeArchived) {
1012
+ rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1013
+ } else {
1014
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1015
+ }
977
1016
  return rows.map(rowToLoop);
978
1017
  }
979
1018
  dueLoops(now) {
980
1019
  const rows = this.db.query(`SELECT * FROM loops
981
1020
  WHERE status = 'active'
1021
+ AND archived_at IS NULL
982
1022
  AND next_run_at IS NOT NULL
983
1023
  AND next_run_at <= ?
984
1024
  ORDER BY next_run_at ASC`).all(now.toISOString());
@@ -1010,6 +1050,44 @@ class Store {
1010
1050
  throw new Error(`loop not found after update: ${id}`);
1011
1051
  return after;
1012
1052
  }
1053
+ archiveLoop(idOrName) {
1054
+ const loop = this.requireLoop(idOrName);
1055
+ if (loop.archivedAt)
1056
+ return loop;
1057
+ const updated = nowIso();
1058
+ const archivedStatus = loop.status === "active" ? "paused" : loop.status;
1059
+ this.db.query(`UPDATE loops
1060
+ SET status=$status, archived_at=$archivedAt, archived_from_status=$archivedFromStatus, updated_at=$updated
1061
+ WHERE id=$id`).run({
1062
+ $id: loop.id,
1063
+ $status: archivedStatus,
1064
+ $archivedAt: updated,
1065
+ $archivedFromStatus: loop.status,
1066
+ $updated: updated
1067
+ });
1068
+ const archived = this.getLoop(loop.id);
1069
+ if (!archived)
1070
+ throw new Error(`loop not found after archive: ${loop.id}`);
1071
+ return archived;
1072
+ }
1073
+ unarchiveLoop(idOrName) {
1074
+ const loop = this.requireLoop(idOrName);
1075
+ if (!loop.archivedAt)
1076
+ return loop;
1077
+ const updated = nowIso();
1078
+ const restoredStatus = loop.archivedFromStatus ?? loop.status;
1079
+ this.db.query(`UPDATE loops
1080
+ SET status=$status, archived_at=NULL, archived_from_status=NULL, updated_at=$updated
1081
+ WHERE id=$id`).run({
1082
+ $id: loop.id,
1083
+ $status: restoredStatus,
1084
+ $updated: updated
1085
+ });
1086
+ const unarchived = this.getLoop(loop.id);
1087
+ if (!unarchived)
1088
+ throw new Error(`loop not found after unarchive: ${loop.id}`);
1089
+ return unarchived;
1090
+ }
1013
1091
  deleteLoop(idOrName) {
1014
1092
  const loop = this.requireLoop(idOrName);
1015
1093
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
@@ -1784,10 +1862,16 @@ class Store {
1784
1862
  }
1785
1863
  claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1786
1864
  const startedAt = now.toISOString();
1787
- const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1788
1865
  this.db.exec("BEGIN IMMEDIATE");
1789
1866
  try {
1790
1867
  this.assertDaemonLeaseFence(opts, startedAt);
1868
+ const currentLoop = this.getLoop(loop.id);
1869
+ if (!currentLoop || currentLoop.archivedAt) {
1870
+ this.db.exec("COMMIT");
1871
+ return;
1872
+ }
1873
+ loop = currentLoop;
1874
+ const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1791
1875
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1792
1876
  if (existing) {
1793
1877
  if (existing.status === "running") {
@@ -2005,7 +2089,7 @@ class Store {
2005
2089
  return recovered;
2006
2090
  }
2007
2091
  expireLoops(now = new Date, opts = {}) {
2008
- const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
2092
+ 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());
2009
2093
  const expired = [];
2010
2094
  for (const row of rows) {
2011
2095
  const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
@@ -2014,8 +2098,21 @@ class Store {
2014
2098
  }
2015
2099
  return expired;
2016
2100
  }
2017
- countLoops(status) {
2018
- 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();
2101
+ countLoops(status, opts = {}) {
2102
+ let row;
2103
+ if (status && opts.archived) {
2104
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NOT NULL").get(status);
2105
+ } else if (status && opts.includeArchived) {
2106
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status);
2107
+ } else if (status) {
2108
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NULL").get(status);
2109
+ } else if (opts.archived) {
2110
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NOT NULL").get();
2111
+ } else if (opts.includeArchived) {
2112
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops").get();
2113
+ } else {
2114
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NULL").get();
2115
+ }
2019
2116
  return row?.count ?? 0;
2020
2117
  }
2021
2118
  countRuns(status) {
@@ -8,7 +8,13 @@ export interface TodosTaskWorkflowTemplateInput {
8
8
  projectPath: string;
9
9
  provider?: AgentProvider;
10
10
  authProfile?: string;
11
+ authProfilePool?: string[];
12
+ workerAuthProfile?: string;
13
+ verifierAuthProfile?: string;
11
14
  account?: AccountRef;
15
+ accountPool?: AccountRef[];
16
+ workerAccount?: AccountRef;
17
+ verifierAccount?: AccountRef;
12
18
  model?: string;
13
19
  variant?: string;
14
20
  agent?: string;
@@ -27,7 +33,13 @@ export interface EventWorkflowTemplateInput {
27
33
  projectPath: string;
28
34
  provider?: AgentProvider;
29
35
  authProfile?: string;
36
+ authProfilePool?: string[];
37
+ workerAuthProfile?: string;
38
+ verifierAuthProfile?: string;
30
39
  account?: AccountRef;
40
+ accountPool?: AccountRef[];
41
+ workerAccount?: AccountRef;
42
+ verifierAccount?: AccountRef;
31
43
  model?: string;
32
44
  variant?: string;
33
45
  agent?: string;
@@ -17,6 +17,8 @@ export declare class LoopsClient {
17
17
  pause(idOrName: string): Loop;
18
18
  resume(idOrName: string): Loop;
19
19
  stop(idOrName: string): Loop;
20
+ archive(idOrName: string): Loop;
21
+ unarchive(idOrName: string): Loop;
20
22
  delete(idOrName: string): boolean;
21
23
  runs(loopId?: string): LoopRun[];
22
24
  goal(idOrName: string): {
package/dist/sdk/index.js CHANGED
@@ -326,6 +326,17 @@ function optionalPositiveInteger(value, label) {
326
326
  throw new Error(`${label} must be a positive integer`);
327
327
  return value;
328
328
  }
329
+ function optionalStringArray(value, label) {
330
+ if (value === undefined)
331
+ return;
332
+ if (!Array.isArray(value))
333
+ throw new Error(`${label} must be an array`);
334
+ const values = value.map((entry, index) => {
335
+ assertString(entry, `${label}[${index}]`);
336
+ return entry.trim();
337
+ }).filter(Boolean);
338
+ return values.length ? values : undefined;
339
+ }
329
340
  function normalizeGoalSpec(value, label = "goal") {
330
341
  if (value === undefined)
331
342
  return;
@@ -397,6 +408,14 @@ function validateTarget(value, label) {
397
408
  throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
398
409
  }
399
410
  }
411
+ if (value.allowlist !== undefined) {
412
+ assertObject(value.allowlist, `${label}.allowlist`);
413
+ optionalStringArray(value.allowlist.tools, `${label}.allowlist.tools`);
414
+ optionalStringArray(value.allowlist.commands, `${label}.allowlist.commands`);
415
+ if (value.allowlist.enforcement !== undefined && value.allowlist.enforcement !== "metadata_only") {
416
+ throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
417
+ }
418
+ }
400
419
  return value;
401
420
  }
402
421
  throw new Error(`${label}.type must be command or agent`);
@@ -480,6 +499,8 @@ function rowToLoop(row) {
480
499
  name: row.name,
481
500
  description: row.description ?? undefined,
482
501
  status: row.status,
502
+ archivedAt: row.archived_at ?? undefined,
503
+ archivedFromStatus: row.archived_from_status ? row.archived_from_status : undefined,
483
504
  schedule: JSON.parse(row.schedule_json),
484
505
  target: JSON.parse(row.target_json),
485
506
  goal: row.goal_json ? JSON.parse(row.goal_json) : undefined,
@@ -689,6 +710,8 @@ class Store {
689
710
  name TEXT NOT NULL,
690
711
  description TEXT,
691
712
  status TEXT NOT NULL,
713
+ archived_at TEXT,
714
+ archived_from_status TEXT,
692
715
  schedule_json TEXT NOT NULL,
693
716
  target_json TEXT NOT NULL,
694
717
  goal_json TEXT,
@@ -889,6 +912,8 @@ class Store {
889
912
  `);
890
913
  this.addColumnIfMissing("loops", "machine_json", "TEXT");
891
914
  this.addColumnIfMissing("loops", "goal_json", "TEXT");
915
+ this.addColumnIfMissing("loops", "archived_at", "TEXT");
916
+ this.addColumnIfMissing("loops", "archived_from_status", "TEXT");
892
917
  this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
893
918
  this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
894
919
  this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
@@ -897,6 +922,7 @@ class Store {
897
922
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
898
923
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
899
924
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
925
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
900
926
  }
901
927
  addColumnIfMissing(table, column, definition) {
902
928
  const columns = this.db.query(`PRAGMA table_info(${table})`).all();
@@ -973,12 +999,26 @@ class Store {
973
999
  }
974
1000
  listLoops(opts = {}) {
975
1001
  const limit = opts.limit ?? 200;
976
- 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);
1002
+ let rows;
1003
+ if (opts.status && opts.archived) {
1004
+ 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);
1005
+ } else if (opts.status && opts.includeArchived) {
1006
+ rows = this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit);
1007
+ } else if (opts.status) {
1008
+ 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);
1009
+ } else if (opts.archived) {
1010
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT ?").all(limit);
1011
+ } else if (opts.includeArchived) {
1012
+ rows = this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1013
+ } else {
1014
+ rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
1015
+ }
977
1016
  return rows.map(rowToLoop);
978
1017
  }
979
1018
  dueLoops(now) {
980
1019
  const rows = this.db.query(`SELECT * FROM loops
981
1020
  WHERE status = 'active'
1021
+ AND archived_at IS NULL
982
1022
  AND next_run_at IS NOT NULL
983
1023
  AND next_run_at <= ?
984
1024
  ORDER BY next_run_at ASC`).all(now.toISOString());
@@ -1010,6 +1050,44 @@ class Store {
1010
1050
  throw new Error(`loop not found after update: ${id}`);
1011
1051
  return after;
1012
1052
  }
1053
+ archiveLoop(idOrName) {
1054
+ const loop = this.requireLoop(idOrName);
1055
+ if (loop.archivedAt)
1056
+ return loop;
1057
+ const updated = nowIso();
1058
+ const archivedStatus = loop.status === "active" ? "paused" : loop.status;
1059
+ this.db.query(`UPDATE loops
1060
+ SET status=$status, archived_at=$archivedAt, archived_from_status=$archivedFromStatus, updated_at=$updated
1061
+ WHERE id=$id`).run({
1062
+ $id: loop.id,
1063
+ $status: archivedStatus,
1064
+ $archivedAt: updated,
1065
+ $archivedFromStatus: loop.status,
1066
+ $updated: updated
1067
+ });
1068
+ const archived = this.getLoop(loop.id);
1069
+ if (!archived)
1070
+ throw new Error(`loop not found after archive: ${loop.id}`);
1071
+ return archived;
1072
+ }
1073
+ unarchiveLoop(idOrName) {
1074
+ const loop = this.requireLoop(idOrName);
1075
+ if (!loop.archivedAt)
1076
+ return loop;
1077
+ const updated = nowIso();
1078
+ const restoredStatus = loop.archivedFromStatus ?? loop.status;
1079
+ this.db.query(`UPDATE loops
1080
+ SET status=$status, archived_at=NULL, archived_from_status=NULL, updated_at=$updated
1081
+ WHERE id=$id`).run({
1082
+ $id: loop.id,
1083
+ $status: restoredStatus,
1084
+ $updated: updated
1085
+ });
1086
+ const unarchived = this.getLoop(loop.id);
1087
+ if (!unarchived)
1088
+ throw new Error(`loop not found after unarchive: ${loop.id}`);
1089
+ return unarchived;
1090
+ }
1013
1091
  deleteLoop(idOrName) {
1014
1092
  const loop = this.requireLoop(idOrName);
1015
1093
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
@@ -1784,10 +1862,16 @@ class Store {
1784
1862
  }
1785
1863
  claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1786
1864
  const startedAt = now.toISOString();
1787
- const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1788
1865
  this.db.exec("BEGIN IMMEDIATE");
1789
1866
  try {
1790
1867
  this.assertDaemonLeaseFence(opts, startedAt);
1868
+ const currentLoop = this.getLoop(loop.id);
1869
+ if (!currentLoop || currentLoop.archivedAt) {
1870
+ this.db.exec("COMMIT");
1871
+ return;
1872
+ }
1873
+ loop = currentLoop;
1874
+ const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1791
1875
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1792
1876
  if (existing) {
1793
1877
  if (existing.status === "running") {
@@ -2005,7 +2089,7 @@ class Store {
2005
2089
  return recovered;
2006
2090
  }
2007
2091
  expireLoops(now = new Date, opts = {}) {
2008
- const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
2092
+ 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());
2009
2093
  const expired = [];
2010
2094
  for (const row of rows) {
2011
2095
  const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
@@ -2014,8 +2098,21 @@ class Store {
2014
2098
  }
2015
2099
  return expired;
2016
2100
  }
2017
- countLoops(status) {
2018
- 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();
2101
+ countLoops(status, opts = {}) {
2102
+ let row;
2103
+ if (status && opts.archived) {
2104
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NOT NULL").get(status);
2105
+ } else if (status && opts.includeArchived) {
2106
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status);
2107
+ } else if (status) {
2108
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ? AND archived_at IS NULL").get(status);
2109
+ } else if (opts.archived) {
2110
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NOT NULL").get();
2111
+ } else if (opts.includeArchived) {
2112
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops").get();
2113
+ } else {
2114
+ row = this.db.query("SELECT COUNT(*) AS count FROM loops WHERE archived_at IS NULL").get();
2115
+ }
2019
2116
  return row?.count ?? 0;
2020
2117
  }
2021
2118
  countRuns(status) {
@@ -2377,6 +2474,16 @@ function metadataEnv(metadata) {
2377
2474
  env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
2378
2475
  return env;
2379
2476
  }
2477
+ function allowlistEnv(allowlist) {
2478
+ const env = {};
2479
+ if (allowlist?.tools?.length)
2480
+ env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
2481
+ if (allowlist?.commands?.length)
2482
+ env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
2483
+ if (allowlist?.tools?.length || allowlist?.commands?.length)
2484
+ env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
2485
+ return env;
2486
+ }
2380
2487
  function providerCommand(provider) {
2381
2488
  switch (provider) {
2382
2489
  case "claude":
@@ -2584,7 +2691,8 @@ function commandSpec(target) {
2584
2691
  account: agentTarget.account,
2585
2692
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2586
2693
  preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
2587
- stdin: agentTarget.prompt
2694
+ stdin: agentTarget.prompt,
2695
+ allowlist: agentTarget.allowlist
2588
2696
  };
2589
2697
  }
2590
2698
  function executionEnv(spec, metadata, opts) {
@@ -2596,6 +2704,7 @@ function executionEnv(spec, metadata, opts) {
2596
2704
  Object.assign(env, accountEnv);
2597
2705
  }
2598
2706
  Object.assign(env, spec.env ?? {});
2707
+ Object.assign(env, allowlistEnv(spec.allowlist));
2599
2708
  env.PATH = normalizeExecutionPath(env);
2600
2709
  Object.assign(env, metadataEnv(metadata));
2601
2710
  return env;
@@ -2634,6 +2743,9 @@ function remoteBootstrapLines(spec, metadata) {
2634
2743
  continue;
2635
2744
  lines.push(`export ${key}=${shellQuote(value)}`);
2636
2745
  }
2746
+ for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
2747
+ lines.push(`export ${key}=${shellQuote(value)}`);
2748
+ }
2637
2749
  return lines;
2638
2750
  }
2639
2751
  function remoteScript(spec, metadata) {
@@ -3587,12 +3699,16 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
3587
3699
 
3588
3700
  // src/lib/scheduler.ts
3589
3701
  function manualRunScheduledFor(loop, now = new Date) {
3702
+ if (loop.archivedAt)
3703
+ return now.toISOString();
3590
3704
  if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
3591
3705
  return loop.retryScheduledFor ?? loop.nextRunAt;
3592
3706
  }
3593
3707
  return now.toISOString();
3594
3708
  }
3595
3709
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
3710
+ if (loop.archivedAt)
3711
+ return false;
3596
3712
  if (loop.status !== "active")
3597
3713
  return false;
3598
3714
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
@@ -3600,6 +3716,8 @@ function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
3600
3716
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
3601
3717
  }
3602
3718
  function manualRunSource(loop, scheduledFor, now = new Date) {
3719
+ if (loop.archivedAt)
3720
+ return "ad_hoc";
3603
3721
  if (loop.status !== "active")
3604
3722
  return "ad_hoc";
3605
3723
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
@@ -3618,7 +3736,7 @@ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
3618
3736
  if (run.status === "running")
3619
3737
  return;
3620
3738
  const current = store.getLoop(loop.id);
3621
- if (!current || current.status !== "active")
3739
+ if (!current || current.status !== "active" || current.archivedAt)
3622
3740
  return;
3623
3741
  if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
3624
3742
  return;
@@ -3903,16 +4021,28 @@ class LoopsClient {
3903
4021
  }
3904
4022
  pause(idOrName) {
3905
4023
  const loop = this.get(idOrName);
4024
+ if (loop.archivedAt)
4025
+ throw new Error(`loop is archived; unarchive it before pausing: ${idOrName}`);
3906
4026
  return this.store.updateLoop(loop.id, { status: "paused" });
3907
4027
  }
3908
4028
  resume(idOrName) {
3909
4029
  const loop = this.get(idOrName);
4030
+ if (loop.archivedAt)
4031
+ throw new Error(`loop is archived; unarchive it before resuming: ${idOrName}`);
3910
4032
  return this.store.updateLoop(loop.id, { status: "active" });
3911
4033
  }
3912
4034
  stop(idOrName) {
3913
4035
  const loop = this.get(idOrName);
4036
+ if (loop.archivedAt)
4037
+ throw new Error(`loop is archived; unarchive it before stopping: ${idOrName}`);
3914
4038
  return this.store.updateLoop(loop.id, { status: "stopped", nextRunAt: undefined });
3915
4039
  }
4040
+ archive(idOrName) {
4041
+ return this.store.archiveLoop(idOrName);
4042
+ }
4043
+ unarchive(idOrName) {
4044
+ return this.store.unarchiveLoop(idOrName);
4045
+ }
3916
4046
  delete(idOrName) {
3917
4047
  return this.store.deleteLoop(idOrName);
3918
4048
  }
@@ -3931,6 +4061,8 @@ class LoopsClient {
3931
4061
  }
3932
4062
  async runNow(idOrName) {
3933
4063
  const loop = this.get(idOrName);
4064
+ if (loop.archivedAt)
4065
+ throw new Error(`loop is archived; unarchive it before running: ${idOrName}`);
3934
4066
  const now = new Date;
3935
4067
  let scheduledFor = manualRunScheduledFor(loop, now);
3936
4068
  let shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
package/dist/types.d.ts CHANGED
@@ -54,6 +54,11 @@ export type AgentProvider = "claude" | "cursor" | "codewith" | "aicopilot" | "op
54
54
  export type AgentConfigIsolation = "safe" | "none";
55
55
  export type AgentPermissionMode = "default" | "plan" | "auto" | "bypass";
56
56
  export type AgentSandbox = "read-only" | "workspace-write" | "danger-full-access" | "enabled" | "disabled";
57
+ export interface AgentAllowlistSpec {
58
+ tools?: string[];
59
+ commands?: string[];
60
+ enforcement?: "metadata_only";
61
+ }
57
62
  export interface AgentTarget {
58
63
  type: "agent";
59
64
  provider: AgentProvider;
@@ -68,6 +73,7 @@ export interface AgentTarget {
68
73
  configIsolation?: AgentConfigIsolation;
69
74
  permissionMode?: AgentPermissionMode;
70
75
  sandbox?: AgentSandbox;
76
+ allowlist?: AgentAllowlistSpec;
71
77
  account?: AccountRef;
72
78
  }
73
79
  export interface WorkflowTarget {
@@ -175,6 +181,8 @@ export interface Loop {
175
181
  name: string;
176
182
  description?: string;
177
183
  status: LoopStatus;
184
+ archivedAt?: string;
185
+ archivedFromStatus?: LoopStatus;
178
186
  schedule: ScheduleSpec;
179
187
  target: LoopTarget;
180
188
  goal?: GoalSpec;