@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.
- package/README.md +20 -6
- package/dist/cli/index.js +590 -26
- package/dist/daemon/control.d.ts +1 -0
- package/dist/daemon/index.js +128 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +413 -15
- package/dist/lib/health.d.ts +70 -0
- package/dist/lib/store.d.ts +8 -1
- package/dist/lib/store.js +102 -5
- package/dist/lib/templates.d.ts +12 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +139 -7
- package/dist/types.d.ts +8 -0
- package/docs/USAGE.md +66 -6
- package/package.json +1 -1
|
@@ -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;
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/lib/templates.d.ts
CHANGED
|
@@ -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;
|
package/dist/sdk/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|