@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
package/dist/cli/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -2074,7 +2171,7 @@ class Store {
|
|
|
2074
2171
|
}
|
|
2075
2172
|
|
|
2076
2173
|
// src/cli/index.ts
|
|
2077
|
-
import { createHash } from "crypto";
|
|
2174
|
+
import { createHash as createHash2 } from "crypto";
|
|
2078
2175
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
2079
2176
|
import { Command } from "commander";
|
|
2080
2177
|
|
|
@@ -2493,6 +2590,16 @@ function metadataEnv(metadata) {
|
|
|
2493
2590
|
env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
|
|
2494
2591
|
return env;
|
|
2495
2592
|
}
|
|
2593
|
+
function allowlistEnv(allowlist) {
|
|
2594
|
+
const env = {};
|
|
2595
|
+
if (allowlist?.tools?.length)
|
|
2596
|
+
env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
|
|
2597
|
+
if (allowlist?.commands?.length)
|
|
2598
|
+
env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
|
|
2599
|
+
if (allowlist?.tools?.length || allowlist?.commands?.length)
|
|
2600
|
+
env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
|
|
2601
|
+
return env;
|
|
2602
|
+
}
|
|
2496
2603
|
function providerCommand(provider) {
|
|
2497
2604
|
switch (provider) {
|
|
2498
2605
|
case "claude":
|
|
@@ -2700,7 +2807,8 @@ function commandSpec(target) {
|
|
|
2700
2807
|
account: agentTarget.account,
|
|
2701
2808
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
2702
2809
|
preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
|
|
2703
|
-
stdin: agentTarget.prompt
|
|
2810
|
+
stdin: agentTarget.prompt,
|
|
2811
|
+
allowlist: agentTarget.allowlist
|
|
2704
2812
|
};
|
|
2705
2813
|
}
|
|
2706
2814
|
function executionEnv(spec, metadata, opts) {
|
|
@@ -2712,6 +2820,7 @@ function executionEnv(spec, metadata, opts) {
|
|
|
2712
2820
|
Object.assign(env, accountEnv);
|
|
2713
2821
|
}
|
|
2714
2822
|
Object.assign(env, spec.env ?? {});
|
|
2823
|
+
Object.assign(env, allowlistEnv(spec.allowlist));
|
|
2715
2824
|
env.PATH = normalizeExecutionPath(env);
|
|
2716
2825
|
Object.assign(env, metadataEnv(metadata));
|
|
2717
2826
|
return env;
|
|
@@ -2750,6 +2859,9 @@ function remoteBootstrapLines(spec, metadata) {
|
|
|
2750
2859
|
continue;
|
|
2751
2860
|
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
2752
2861
|
}
|
|
2862
|
+
for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
|
|
2863
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
2864
|
+
}
|
|
2753
2865
|
return lines;
|
|
2754
2866
|
}
|
|
2755
2867
|
function remoteScript(spec, metadata) {
|
|
@@ -3703,12 +3815,16 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
3703
3815
|
|
|
3704
3816
|
// src/lib/scheduler.ts
|
|
3705
3817
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
3818
|
+
if (loop.archivedAt)
|
|
3819
|
+
return now.toISOString();
|
|
3706
3820
|
if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
3707
3821
|
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
3708
3822
|
}
|
|
3709
3823
|
return now.toISOString();
|
|
3710
3824
|
}
|
|
3711
3825
|
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
3826
|
+
if (loop.archivedAt)
|
|
3827
|
+
return false;
|
|
3712
3828
|
if (loop.status !== "active")
|
|
3713
3829
|
return false;
|
|
3714
3830
|
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
@@ -3716,6 +3832,8 @@ function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
|
3716
3832
|
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
3717
3833
|
}
|
|
3718
3834
|
function manualRunSource(loop, scheduledFor, now = new Date) {
|
|
3835
|
+
if (loop.archivedAt)
|
|
3836
|
+
return "ad_hoc";
|
|
3719
3837
|
if (loop.status !== "active")
|
|
3720
3838
|
return "ad_hoc";
|
|
3721
3839
|
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
@@ -3734,7 +3852,7 @@ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
|
|
|
3734
3852
|
if (run.status === "running")
|
|
3735
3853
|
return;
|
|
3736
3854
|
const current = store.getLoop(loop.id);
|
|
3737
|
-
if (!current || current.status !== "active")
|
|
3855
|
+
if (!current || current.status !== "active" || current.archivedAt)
|
|
3738
3856
|
return;
|
|
3739
3857
|
if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
|
|
3740
3858
|
return;
|
|
@@ -4069,7 +4187,8 @@ function daemonStatus(store, path = pidFilePath()) {
|
|
|
4069
4187
|
active: store.countLoops("active"),
|
|
4070
4188
|
paused: store.countLoops("paused"),
|
|
4071
4189
|
stopped: store.countLoops("stopped"),
|
|
4072
|
-
expired: store.countLoops("expired")
|
|
4190
|
+
expired: store.countLoops("expired"),
|
|
4191
|
+
archived: store.countLoops(undefined, { archived: true })
|
|
4073
4192
|
},
|
|
4074
4193
|
runs: {
|
|
4075
4194
|
total: store.countRuns(),
|
|
@@ -4492,10 +4611,220 @@ function runDoctor(store) {
|
|
|
4492
4611
|
checks
|
|
4493
4612
|
};
|
|
4494
4613
|
}
|
|
4614
|
+
|
|
4615
|
+
// src/lib/health.ts
|
|
4616
|
+
import { createHash } from "crypto";
|
|
4617
|
+
var EVIDENCE_CHARS = 2000;
|
|
4618
|
+
var CLASSIFICATIONS = [
|
|
4619
|
+
"rate_limit",
|
|
4620
|
+
"auth",
|
|
4621
|
+
"model_not_found",
|
|
4622
|
+
"context_length",
|
|
4623
|
+
"schema_response_format",
|
|
4624
|
+
"node_init",
|
|
4625
|
+
"timeout",
|
|
4626
|
+
"sigsegv",
|
|
4627
|
+
"skipped_previous_active",
|
|
4628
|
+
"unknown"
|
|
4629
|
+
];
|
|
4630
|
+
function bounded(value, limit = EVIDENCE_CHARS) {
|
|
4631
|
+
if (!value)
|
|
4632
|
+
return;
|
|
4633
|
+
if (value.length <= limit)
|
|
4634
|
+
return value;
|
|
4635
|
+
return `${value.slice(0, limit)}
|
|
4636
|
+
[truncated ${value.length - limit} chars]`;
|
|
4637
|
+
}
|
|
4638
|
+
function searchableText(run) {
|
|
4639
|
+
return [run.error, run.stderr, run.stdout].filter(Boolean).join(`
|
|
4640
|
+
`).toLowerCase();
|
|
4641
|
+
}
|
|
4642
|
+
function stableFingerprint(parts) {
|
|
4643
|
+
return createHash("sha256").update(parts.join(`
|
|
4644
|
+
`)).digest("hex").slice(0, 16);
|
|
4645
|
+
}
|
|
4646
|
+
function healthRun(run) {
|
|
4647
|
+
return {
|
|
4648
|
+
...run,
|
|
4649
|
+
error: bounded(run.error),
|
|
4650
|
+
stdout: bounded(run.stdout),
|
|
4651
|
+
stderr: bounded(run.stderr)
|
|
4652
|
+
};
|
|
4653
|
+
}
|
|
4654
|
+
function classifyRunFailure(run) {
|
|
4655
|
+
if (run.status === "succeeded" || run.status === "running")
|
|
4656
|
+
return;
|
|
4657
|
+
const text = searchableText(run);
|
|
4658
|
+
let classification = "unknown";
|
|
4659
|
+
if (run.status === "timed_out")
|
|
4660
|
+
classification = "timeout";
|
|
4661
|
+
else if (run.status === "skipped" && /previous run still active/.test(text))
|
|
4662
|
+
classification = "skipped_previous_active";
|
|
4663
|
+
else if (/rate limit|too many requests|429\b|quota exceeded/.test(text))
|
|
4664
|
+
classification = "rate_limit";
|
|
4665
|
+
else if (/unauthorized|authentication|auth\b|api key|invalid token|permission denied|401\b|403\b/.test(text))
|
|
4666
|
+
classification = "auth";
|
|
4667
|
+
else if (/model .*not found|model_not_found|unknown model|invalid model|404.*model/.test(text))
|
|
4668
|
+
classification = "model_not_found";
|
|
4669
|
+
else if (/context length|context_length|context window|maximum context|token limit|too many tokens/.test(text))
|
|
4670
|
+
classification = "context_length";
|
|
4671
|
+
else if (/response_format|json schema|schema validation|invalid schema|structured output/.test(text))
|
|
4672
|
+
classification = "schema_response_format";
|
|
4673
|
+
else if (/cannot find module|module not found|node:internal|bun: command not found|node: command not found|npm err!|err_module_not_found/.test(text))
|
|
4674
|
+
classification = "node_init";
|
|
4675
|
+
else if (/sigsegv|segmentation fault|signal 11/.test(text))
|
|
4676
|
+
classification = "sigsegv";
|
|
4677
|
+
return {
|
|
4678
|
+
classification,
|
|
4679
|
+
fingerprint: stableFingerprint([
|
|
4680
|
+
run.loopId,
|
|
4681
|
+
run.loopName,
|
|
4682
|
+
run.status,
|
|
4683
|
+
classification,
|
|
4684
|
+
String(run.exitCode ?? ""),
|
|
4685
|
+
(run.error ?? run.stderr ?? run.stdout ?? "").slice(0, 500)
|
|
4686
|
+
]),
|
|
4687
|
+
evidence: {
|
|
4688
|
+
error: bounded(run.error),
|
|
4689
|
+
stdout: bounded(run.stdout),
|
|
4690
|
+
stderr: bounded(run.stderr),
|
|
4691
|
+
exitCode: run.exitCode
|
|
4692
|
+
}
|
|
4693
|
+
};
|
|
4694
|
+
}
|
|
4695
|
+
function targetRoute(loop) {
|
|
4696
|
+
if (loop.target.type === "agent") {
|
|
4697
|
+
return {
|
|
4698
|
+
source: "openloops",
|
|
4699
|
+
kind: "loop_expectation",
|
|
4700
|
+
loopId: loop.id,
|
|
4701
|
+
loopName: loop.name,
|
|
4702
|
+
cwd: loop.target.cwd,
|
|
4703
|
+
provider: loop.target.provider
|
|
4704
|
+
};
|
|
4705
|
+
}
|
|
4706
|
+
if (loop.target.type === "command") {
|
|
4707
|
+
return {
|
|
4708
|
+
source: "openloops",
|
|
4709
|
+
kind: "loop_expectation",
|
|
4710
|
+
loopId: loop.id,
|
|
4711
|
+
loopName: loop.name,
|
|
4712
|
+
cwd: loop.target.cwd
|
|
4713
|
+
};
|
|
4714
|
+
}
|
|
4715
|
+
return {
|
|
4716
|
+
source: "openloops",
|
|
4717
|
+
kind: "loop_expectation",
|
|
4718
|
+
loopId: loop.id,
|
|
4719
|
+
loopName: loop.name
|
|
4720
|
+
};
|
|
4721
|
+
}
|
|
4722
|
+
function recommendedTask(loop, run, failure, route) {
|
|
4723
|
+
const title = `BUG: open-loops loop failure - ${loop.name}`;
|
|
4724
|
+
const description = [
|
|
4725
|
+
`OpenLoops expectation failed for loop ${loop.name} (${loop.id}).`,
|
|
4726
|
+
`Run: ${run.id}`,
|
|
4727
|
+
`Status: ${run.status}`,
|
|
4728
|
+
`Classification: ${failure.classification}`,
|
|
4729
|
+
`Fingerprint: ${failure.fingerprint}`,
|
|
4730
|
+
route.cwd ? `Route cwd: ${route.cwd}` : undefined,
|
|
4731
|
+
route.provider ? `Provider: ${route.provider}` : undefined,
|
|
4732
|
+
failure.evidence.error ? `Error:
|
|
4733
|
+
${failure.evidence.error}` : undefined,
|
|
4734
|
+
failure.evidence.stderr ? `Stderr:
|
|
4735
|
+
${failure.evidence.stderr}` : undefined
|
|
4736
|
+
].filter(Boolean).join(`
|
|
4737
|
+
|
|
4738
|
+
`);
|
|
4739
|
+
const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
|
|
4740
|
+
const tags = ["bug", "openloops", "loop-health", failure.classification];
|
|
4741
|
+
const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
|
|
4742
|
+
return {
|
|
4743
|
+
title,
|
|
4744
|
+
description,
|
|
4745
|
+
priority,
|
|
4746
|
+
tags,
|
|
4747
|
+
dedupeKey,
|
|
4748
|
+
search: { query: dedupeKey },
|
|
4749
|
+
compatibilityFallback: {
|
|
4750
|
+
search: ["todos", "search", dedupeKey, "--json"],
|
|
4751
|
+
add: ["todos", "add", title, "--description", description, "--tag", tags.join(","), "--priority", priority],
|
|
4752
|
+
comment: ["todos", "comment", "<task-id>", description]
|
|
4753
|
+
},
|
|
4754
|
+
futureNativeUpsert: {
|
|
4755
|
+
command: "todos upsert",
|
|
4756
|
+
fields: {
|
|
4757
|
+
title,
|
|
4758
|
+
description,
|
|
4759
|
+
priority,
|
|
4760
|
+
tags,
|
|
4761
|
+
dedupeKey,
|
|
4762
|
+
routeSource: route.source,
|
|
4763
|
+
routeKind: route.kind,
|
|
4764
|
+
routeLoopId: route.loopId,
|
|
4765
|
+
routeLoopName: route.loopName
|
|
4766
|
+
}
|
|
4767
|
+
}
|
|
4768
|
+
};
|
|
4769
|
+
}
|
|
4770
|
+
function expectationForLoop(store, loop) {
|
|
4771
|
+
const latestRun = store.listRuns({ loopId: loop.id, limit: 1 })[0];
|
|
4772
|
+
const route = targetRoute(loop);
|
|
4773
|
+
if (!latestRun) {
|
|
4774
|
+
return {
|
|
4775
|
+
loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
|
|
4776
|
+
ok: true,
|
|
4777
|
+
check: { id: "latest-run-succeeded", status: "warn", message: "loop has no recorded runs yet" },
|
|
4778
|
+
route
|
|
4779
|
+
};
|
|
4780
|
+
}
|
|
4781
|
+
if (latestRun.status === "succeeded") {
|
|
4782
|
+
return {
|
|
4783
|
+
loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
|
|
4784
|
+
ok: true,
|
|
4785
|
+
check: { id: "latest-run-succeeded", status: "pass", message: "latest run succeeded" },
|
|
4786
|
+
latestRun: healthRun(latestRun),
|
|
4787
|
+
route
|
|
4788
|
+
};
|
|
4789
|
+
}
|
|
4790
|
+
const failure = classifyRunFailure(latestRun);
|
|
4791
|
+
return {
|
|
4792
|
+
loop: { id: loop.id, name: loop.name, status: loop.status, nextRunAt: loop.nextRunAt },
|
|
4793
|
+
ok: false,
|
|
4794
|
+
check: { id: "latest-run-succeeded", status: "fail", message: `latest run is ${latestRun.status}` },
|
|
4795
|
+
latestRun: healthRun(latestRun),
|
|
4796
|
+
failure,
|
|
4797
|
+
route,
|
|
4798
|
+
recommendedTask: failure ? recommendedTask(loop, latestRun, failure, route) : undefined
|
|
4799
|
+
};
|
|
4800
|
+
}
|
|
4801
|
+
function buildHealthReport(store, opts = {}) {
|
|
4802
|
+
const loops = store.listLoops({ includeArchived: opts.includeArchived, limit: opts.limit ?? 200 });
|
|
4803
|
+
const expectations = loops.map((loop) => expectationForLoop(store, loop));
|
|
4804
|
+
const classifications = Object.fromEntries(CLASSIFICATIONS.map((key) => [key, 0]));
|
|
4805
|
+
for (const expectation of expectations) {
|
|
4806
|
+
if (expectation.failure)
|
|
4807
|
+
classifications[expectation.failure.classification] += 1;
|
|
4808
|
+
}
|
|
4809
|
+
const unhealthy = expectations.filter((expectation) => !expectation.ok).length;
|
|
4810
|
+
const warnings = expectations.filter((expectation) => expectation.check.status === "warn").length;
|
|
4811
|
+
return {
|
|
4812
|
+
ok: unhealthy === 0,
|
|
4813
|
+
generatedAt: new Date().toISOString(),
|
|
4814
|
+
summary: {
|
|
4815
|
+
loops: expectations.length,
|
|
4816
|
+
healthy: expectations.length - unhealthy,
|
|
4817
|
+
unhealthy,
|
|
4818
|
+
warnings
|
|
4819
|
+
},
|
|
4820
|
+
classifications,
|
|
4821
|
+
expectations
|
|
4822
|
+
};
|
|
4823
|
+
}
|
|
4495
4824
|
// package.json
|
|
4496
4825
|
var package_default = {
|
|
4497
4826
|
name: "@hasna/loops",
|
|
4498
|
-
version: "0.3.
|
|
4827
|
+
version: "0.3.16",
|
|
4499
4828
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4500
4829
|
type: "module",
|
|
4501
4830
|
main: "dist/index.js",
|
|
@@ -4598,6 +4927,10 @@ var TEMPLATE_SUMMARIES = [
|
|
|
4598
4927
|
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
4599
4928
|
{ name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
|
|
4600
4929
|
{ name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
|
|
4930
|
+
{ name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
|
|
4931
|
+
{ name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
|
|
4932
|
+
{ name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
|
|
4933
|
+
{ name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
|
|
4601
4934
|
{ name: "model", description: "Provider model." },
|
|
4602
4935
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
4603
4936
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
@@ -4617,6 +4950,10 @@ var TEMPLATE_SUMMARIES = [
|
|
|
4617
4950
|
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
4618
4951
|
{ name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
|
|
4619
4952
|
{ name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
|
|
4953
|
+
{ name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
|
|
4954
|
+
{ name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
|
|
4955
|
+
{ name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
|
|
4956
|
+
{ name: "accountPool", description: "Comma-separated OpenAccounts profiles; worker/verifier are selected deterministically." },
|
|
4620
4957
|
{ name: "model", description: "Provider model." },
|
|
4621
4958
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
4622
4959
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
@@ -4631,7 +4968,37 @@ function taskLabel(input) {
|
|
|
4631
4968
|
const head = input.taskTitle?.trim() || input.taskId;
|
|
4632
4969
|
return head.length > 160 ? `${head.slice(0, 157)}...` : head;
|
|
4633
4970
|
}
|
|
4634
|
-
function
|
|
4971
|
+
function stableIndex(seed, size) {
|
|
4972
|
+
let hash = 2166136261;
|
|
4973
|
+
for (let i = 0;i < seed.length; i += 1) {
|
|
4974
|
+
hash ^= seed.charCodeAt(i);
|
|
4975
|
+
hash = Math.imul(hash, 16777619);
|
|
4976
|
+
}
|
|
4977
|
+
return Math.abs(hash >>> 0) % size;
|
|
4978
|
+
}
|
|
4979
|
+
function rolePoolValue(pool, seed, role) {
|
|
4980
|
+
if (!pool?.length)
|
|
4981
|
+
return;
|
|
4982
|
+
const workerIndex = stableIndex(seed, pool.length);
|
|
4983
|
+
if (role === "worker" || pool.length === 1)
|
|
4984
|
+
return pool[workerIndex];
|
|
4985
|
+
return pool[(workerIndex + 1) % pool.length];
|
|
4986
|
+
}
|
|
4987
|
+
function authProfileForRole(input, role, seed) {
|
|
4988
|
+
if (role === "worker" && input.workerAuthProfile)
|
|
4989
|
+
return input.workerAuthProfile;
|
|
4990
|
+
if (role === "verifier" && input.verifierAuthProfile)
|
|
4991
|
+
return input.verifierAuthProfile;
|
|
4992
|
+
return rolePoolValue(input.authProfilePool, seed, role) ?? input.authProfile;
|
|
4993
|
+
}
|
|
4994
|
+
function accountForRole(input, role, seed) {
|
|
4995
|
+
if (role === "worker" && input.workerAccount)
|
|
4996
|
+
return input.workerAccount;
|
|
4997
|
+
if (role === "verifier" && input.verifierAccount)
|
|
4998
|
+
return input.verifierAccount;
|
|
4999
|
+
return rolePoolValue(input.accountPool, seed, role) ?? input.account;
|
|
5000
|
+
}
|
|
5001
|
+
function agentTarget(input, prompt, role, seed) {
|
|
4635
5002
|
const provider = input.provider ?? "codewith";
|
|
4636
5003
|
const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "danger-full-access" : provider === "cursor" ? "disabled" : undefined);
|
|
4637
5004
|
return {
|
|
@@ -4642,11 +5009,11 @@ function agentTarget(input, prompt) {
|
|
|
4642
5009
|
model: input.model,
|
|
4643
5010
|
variant: input.variant,
|
|
4644
5011
|
agent: input.agent,
|
|
4645
|
-
authProfile: provider === "codewith" ? input
|
|
5012
|
+
authProfile: provider === "codewith" ? authProfileForRole(input, role, seed) : undefined,
|
|
4646
5013
|
configIsolation: "safe",
|
|
4647
5014
|
permissionMode: input.permissionMode ?? "bypass",
|
|
4648
5015
|
sandbox,
|
|
4649
|
-
account: input
|
|
5016
|
+
account: accountForRole(input, role, seed),
|
|
4650
5017
|
timeoutMs: 45 * 60000
|
|
4651
5018
|
};
|
|
4652
5019
|
}
|
|
@@ -4700,7 +5067,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
4700
5067
|
id: "worker",
|
|
4701
5068
|
name: "Worker",
|
|
4702
5069
|
description: "Implement the todos task and record evidence.",
|
|
4703
|
-
target: agentTarget(input, workerPrompt),
|
|
5070
|
+
target: agentTarget(input, workerPrompt, "worker", input.taskId),
|
|
4704
5071
|
timeoutMs: 45 * 60000
|
|
4705
5072
|
},
|
|
4706
5073
|
{
|
|
@@ -4708,7 +5075,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
4708
5075
|
name: "Verifier",
|
|
4709
5076
|
description: "Adversarially verify worker output and update todos.",
|
|
4710
5077
|
dependsOn: ["worker"],
|
|
4711
|
-
target: agentTarget(input, verifierPrompt),
|
|
5078
|
+
target: agentTarget(input, verifierPrompt, "verifier", input.taskId),
|
|
4712
5079
|
timeoutMs: 30 * 60000
|
|
4713
5080
|
}
|
|
4714
5081
|
]
|
|
@@ -4764,7 +5131,7 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
4764
5131
|
id: "worker",
|
|
4765
5132
|
name: "Worker",
|
|
4766
5133
|
description: "Handle the Hasna event and record evidence.",
|
|
4767
|
-
target: agentTarget(input, workerPrompt),
|
|
5134
|
+
target: agentTarget(input, workerPrompt, "worker", `${input.eventSource}:${input.eventType}:${input.eventId}`),
|
|
4768
5135
|
timeoutMs: 45 * 60000
|
|
4769
5136
|
},
|
|
4770
5137
|
{
|
|
@@ -4772,7 +5139,7 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
4772
5139
|
name: "Verifier",
|
|
4773
5140
|
description: "Adversarially verify event handling.",
|
|
4774
5141
|
dependsOn: ["worker"],
|
|
4775
|
-
target: agentTarget(input, verifierPrompt),
|
|
5142
|
+
target: agentTarget(input, verifierPrompt, "verifier", `${input.eventSource}:${input.eventType}:${input.eventId}`),
|
|
4776
5143
|
timeoutMs: 30 * 60000
|
|
4777
5144
|
}
|
|
4778
5145
|
]
|
|
@@ -4787,7 +5154,11 @@ function renderLoopTemplate(id, values) {
|
|
|
4787
5154
|
projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
|
|
4788
5155
|
provider: values.provider,
|
|
4789
5156
|
authProfile: values.authProfile,
|
|
5157
|
+
authProfilePool: listVar(values.authProfilePool),
|
|
5158
|
+
workerAuthProfile: values.workerAuthProfile,
|
|
5159
|
+
verifierAuthProfile: values.verifierAuthProfile,
|
|
4790
5160
|
account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
|
|
5161
|
+
accountPool: accountPoolVar(values.accountPool, values.accountTool),
|
|
4791
5162
|
model: values.model,
|
|
4792
5163
|
variant: values.variant,
|
|
4793
5164
|
agent: values.agent,
|
|
@@ -4808,7 +5179,11 @@ function renderLoopTemplate(id, values) {
|
|
|
4808
5179
|
projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
|
|
4809
5180
|
provider: values.provider,
|
|
4810
5181
|
authProfile: values.authProfile,
|
|
5182
|
+
authProfilePool: listVar(values.authProfilePool),
|
|
5183
|
+
workerAuthProfile: values.workerAuthProfile,
|
|
5184
|
+
verifierAuthProfile: values.verifierAuthProfile,
|
|
4811
5185
|
account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
|
|
5186
|
+
accountPool: accountPoolVar(values.accountPool, values.accountTool),
|
|
4812
5187
|
model: values.model,
|
|
4813
5188
|
variant: values.variant,
|
|
4814
5189
|
agent: values.agent,
|
|
@@ -4818,6 +5193,13 @@ function renderLoopTemplate(id, values) {
|
|
|
4818
5193
|
}
|
|
4819
5194
|
throw new Error(`unknown template: ${id}`);
|
|
4820
5195
|
}
|
|
5196
|
+
function listVar(value) {
|
|
5197
|
+
const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5198
|
+
return values?.length ? values : undefined;
|
|
5199
|
+
}
|
|
5200
|
+
function accountPoolVar(value, tool) {
|
|
5201
|
+
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
5202
|
+
}
|
|
4821
5203
|
|
|
4822
5204
|
// src/cli/index.ts
|
|
4823
5205
|
var program = new Command;
|
|
@@ -4933,10 +5315,32 @@ function goalFromOpts(opts) {
|
|
|
4933
5315
|
}, "goal");
|
|
4934
5316
|
}
|
|
4935
5317
|
function accountFromOpts(opts) {
|
|
4936
|
-
if (!opts.account && opts.accountTool)
|
|
4937
|
-
throw new Error("--account-tool requires --account");
|
|
5318
|
+
if (!opts.account && opts.accountTool && !opts.accountPool && !opts.workerAccount && !opts.verifierAccount) {
|
|
5319
|
+
throw new Error("--account-tool requires --account, --account-pool, --worker-account, or --verifier-account");
|
|
5320
|
+
}
|
|
4938
5321
|
return opts.account ? { profile: opts.account, tool: opts.accountTool } : undefined;
|
|
4939
5322
|
}
|
|
5323
|
+
function splitList(value) {
|
|
5324
|
+
const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5325
|
+
return values?.length ? values : undefined;
|
|
5326
|
+
}
|
|
5327
|
+
function allowlistFromOpts(opts) {
|
|
5328
|
+
const tools = (opts.allowTool ?? []).flatMap((entry) => splitList(entry) ?? []);
|
|
5329
|
+
const commands = (opts.allowCommand ?? []).flatMap((entry) => splitList(entry) ?? []);
|
|
5330
|
+
if (!tools.length && !commands.length)
|
|
5331
|
+
return;
|
|
5332
|
+
return {
|
|
5333
|
+
tools: tools.length ? tools : undefined,
|
|
5334
|
+
commands: commands.length ? commands : undefined,
|
|
5335
|
+
enforcement: "metadata_only"
|
|
5336
|
+
};
|
|
5337
|
+
}
|
|
5338
|
+
function accountPoolFromOpts(opts) {
|
|
5339
|
+
return splitList(opts.accountPool)?.map((profile) => ({ profile, tool: opts.accountTool }));
|
|
5340
|
+
}
|
|
5341
|
+
function roleAccountFromOpts(opts, profile) {
|
|
5342
|
+
return profile ? { profile, tool: opts.accountTool } : undefined;
|
|
5343
|
+
}
|
|
4940
5344
|
function parseVars(values) {
|
|
4941
5345
|
const vars = {};
|
|
4942
5346
|
for (const value of values ?? []) {
|
|
@@ -4970,7 +5374,7 @@ function slugSegment(value, fallback = "event") {
|
|
|
4970
5374
|
return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
|
|
4971
5375
|
}
|
|
4972
5376
|
function stableSuffix(value) {
|
|
4973
|
-
return
|
|
5377
|
+
return createHash2("sha256").update(value).digest("hex").slice(0, 12);
|
|
4974
5378
|
}
|
|
4975
5379
|
function taskEventField(data, keys) {
|
|
4976
5380
|
for (const key of keys) {
|
|
@@ -4996,6 +5400,85 @@ function taskEventField(data, keys) {
|
|
|
4996
5400
|
}
|
|
4997
5401
|
return;
|
|
4998
5402
|
}
|
|
5403
|
+
function objectField(value) {
|
|
5404
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
5405
|
+
}
|
|
5406
|
+
function nestedObject(input, key) {
|
|
5407
|
+
return objectField(input[key]);
|
|
5408
|
+
}
|
|
5409
|
+
function taskEventRecords(data, metadata) {
|
|
5410
|
+
const records = [data];
|
|
5411
|
+
const dataTask = nestedObject(data, "task");
|
|
5412
|
+
if (dataTask)
|
|
5413
|
+
records.push(dataTask);
|
|
5414
|
+
const dataPayload = nestedObject(data, "payload");
|
|
5415
|
+
if (dataPayload) {
|
|
5416
|
+
records.push(dataPayload);
|
|
5417
|
+
const payloadTask = nestedObject(dataPayload, "task");
|
|
5418
|
+
if (payloadTask)
|
|
5419
|
+
records.push(payloadTask);
|
|
5420
|
+
}
|
|
5421
|
+
const dataMetadata = nestedObject(data, "metadata");
|
|
5422
|
+
if (dataMetadata)
|
|
5423
|
+
records.push(dataMetadata);
|
|
5424
|
+
records.push(metadata);
|
|
5425
|
+
const metadataTask = nestedObject(metadata, "task");
|
|
5426
|
+
if (metadataTask)
|
|
5427
|
+
records.push(metadataTask);
|
|
5428
|
+
const metadataAutomation = nestedObject(metadata, "automation");
|
|
5429
|
+
if (metadataAutomation)
|
|
5430
|
+
records.push(metadataAutomation);
|
|
5431
|
+
return records;
|
|
5432
|
+
}
|
|
5433
|
+
function booleanLike(value) {
|
|
5434
|
+
return value === true || value === "true" || value === "1" || value === 1;
|
|
5435
|
+
}
|
|
5436
|
+
function hasTruthyField(records, keys) {
|
|
5437
|
+
return records.some((record) => keys.some((key) => booleanLike(record[key])));
|
|
5438
|
+
}
|
|
5439
|
+
function tagsFromValue(value) {
|
|
5440
|
+
if (Array.isArray(value))
|
|
5441
|
+
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
5442
|
+
if (typeof value === "string")
|
|
5443
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5444
|
+
return [];
|
|
5445
|
+
}
|
|
5446
|
+
function taskEventTags(records) {
|
|
5447
|
+
const tags = new Set;
|
|
5448
|
+
for (const record of records) {
|
|
5449
|
+
for (const tag of tagsFromValue(record.tags ?? record.task_tags ?? record.taskTags))
|
|
5450
|
+
tags.add(tag);
|
|
5451
|
+
}
|
|
5452
|
+
return [...tags];
|
|
5453
|
+
}
|
|
5454
|
+
function taskRouteEligibility(data, metadata) {
|
|
5455
|
+
const records = taskEventRecords(data, metadata);
|
|
5456
|
+
const tags = taskEventTags(records);
|
|
5457
|
+
const hasRouteOptIn = tags.includes("auto:route") || hasTruthyField(records, ["route_enabled", "routeEnabled", "automation_allowed", "automationAllowed", "allowed"]);
|
|
5458
|
+
if (!hasRouteOptIn)
|
|
5459
|
+
return { eligible: false, reason: "missing explicit route opt-in", tags };
|
|
5460
|
+
const status = taskEventField(data, ["status", "task_status", "taskStatus"])?.toLowerCase();
|
|
5461
|
+
if (status && ["blocked", "completed", "done", "cancelled", "canceled", "failed", "archived"].includes(status)) {
|
|
5462
|
+
return { eligible: false, reason: `task status is not routable: ${status}`, tags };
|
|
5463
|
+
}
|
|
5464
|
+
const disallowedTags = tags.filter((tag) => ["no-auto", "manual", "manual-required", "approval-required"].includes(tag));
|
|
5465
|
+
if (disallowedTags.length)
|
|
5466
|
+
return { eligible: false, reason: `task has disallowed tag: ${disallowedTags[0]}`, tags };
|
|
5467
|
+
if (hasTruthyField(records, [
|
|
5468
|
+
"no_auto",
|
|
5469
|
+
"noAuto",
|
|
5470
|
+
"manual",
|
|
5471
|
+
"manual_required",
|
|
5472
|
+
"manualRequired",
|
|
5473
|
+
"requires_approval",
|
|
5474
|
+
"requiresApproval",
|
|
5475
|
+
"approval_required",
|
|
5476
|
+
"approvalRequired"
|
|
5477
|
+
])) {
|
|
5478
|
+
return { eligible: false, reason: "task metadata requires manual or approval-gated handling", tags };
|
|
5479
|
+
}
|
|
5480
|
+
return { eligible: true, tags };
|
|
5481
|
+
}
|
|
4999
5482
|
async function readEventEnvelopeFromStdin() {
|
|
5000
5483
|
const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
|
|
5001
5484
|
const event = JSON.parse(raw);
|
|
@@ -5068,7 +5551,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
|
|
|
5068
5551
|
store.close();
|
|
5069
5552
|
}
|
|
5070
5553
|
});
|
|
5071
|
-
addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--config-isolation <mode>", "safe or none", "safe"))))).action((name, opts) => {
|
|
5554
|
+
addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass").option("--sandbox <mode>", "provider sandbox: codewith/codex use read-only/workspace-write/danger-full-access; cursor uses enabled/disabled").option("--allow-tool <name>", "advisory per-session tool allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--allow-command <name>", "advisory per-session command allowlist metadata; may be repeated or comma-separated", collectValues, []).option("--config-isolation <mode>", "safe or none", "safe"))))).action((name, opts) => {
|
|
5072
5555
|
const provider = opts.provider;
|
|
5073
5556
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
|
|
5074
5557
|
throw new Error("unsupported provider");
|
|
@@ -5091,6 +5574,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
|
|
|
5091
5574
|
configIsolation: opts.configIsolation,
|
|
5092
5575
|
permissionMode: permissionModeFromOpts(opts, provider),
|
|
5093
5576
|
sandbox: sandboxFromOpts(opts, provider),
|
|
5577
|
+
allowlist: allowlistFromOpts(opts),
|
|
5094
5578
|
account: accountFromOpts(opts)
|
|
5095
5579
|
};
|
|
5096
5580
|
const loop = store.createLoop(baseCreateInput(name, opts, target));
|
|
@@ -5149,13 +5633,18 @@ templates.command("create-workflow <id>").description("render and store a templa
|
|
|
5149
5633
|
}
|
|
5150
5634
|
});
|
|
5151
5635
|
var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
|
|
5152
|
-
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--account <profile>", "OpenAccounts profile name").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
5636
|
+
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
5153
5637
|
const event = await readEventEnvelopeFromStdin();
|
|
5154
5638
|
const data = eventData(event);
|
|
5155
5639
|
const metadata = eventMetadata(event);
|
|
5156
5640
|
const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
|
|
5157
5641
|
if (!taskId)
|
|
5158
5642
|
throw new Error("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
|
|
5643
|
+
const eligibility = taskRouteEligibility(data, metadata);
|
|
5644
|
+
if (!eligibility.eligible) {
|
|
5645
|
+
print({ skipped: true, reason: eligibility.reason, event, taskId, eligibility }, `skipped task ${taskId}: ${eligibility.reason}`);
|
|
5646
|
+
return;
|
|
5647
|
+
}
|
|
5159
5648
|
const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
|
|
5160
5649
|
const taskDescription = taskEventField(data, ["description", "body"]);
|
|
5161
5650
|
const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
|
|
@@ -5182,7 +5671,13 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
|
|
|
5182
5671
|
projectPath,
|
|
5183
5672
|
provider,
|
|
5184
5673
|
authProfile,
|
|
5674
|
+
authProfilePool: splitList(opts.authProfilePool),
|
|
5675
|
+
workerAuthProfile: opts.workerAuthProfile,
|
|
5676
|
+
verifierAuthProfile: opts.verifierAuthProfile,
|
|
5185
5677
|
account: accountFromOpts(opts),
|
|
5678
|
+
accountPool: accountPoolFromOpts(opts),
|
|
5679
|
+
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
5680
|
+
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
5186
5681
|
model: opts.model,
|
|
5187
5682
|
variant: opts.variant,
|
|
5188
5683
|
agent: opts.agent,
|
|
@@ -5236,7 +5731,7 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
|
|
|
5236
5731
|
store.close();
|
|
5237
5732
|
}
|
|
5238
5733
|
});
|
|
5239
|
-
eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--account <profile>", "OpenAccounts profile name").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
5734
|
+
eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
5240
5735
|
const event = await readEventEnvelopeFromStdin();
|
|
5241
5736
|
const data = eventData(event);
|
|
5242
5737
|
const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
|
|
@@ -5256,7 +5751,13 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
5256
5751
|
projectPath,
|
|
5257
5752
|
provider,
|
|
5258
5753
|
authProfile,
|
|
5754
|
+
authProfilePool: splitList(opts.authProfilePool),
|
|
5755
|
+
workerAuthProfile: opts.workerAuthProfile,
|
|
5756
|
+
verifierAuthProfile: opts.verifierAuthProfile,
|
|
5259
5757
|
account: accountFromOpts(opts),
|
|
5758
|
+
accountPool: accountPoolFromOpts(opts),
|
|
5759
|
+
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
5760
|
+
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
5260
5761
|
model: opts.model,
|
|
5261
5762
|
variant: opts.variant,
|
|
5262
5763
|
agent: opts.agent,
|
|
@@ -5523,16 +6024,19 @@ workflows.command("archive <idOrName>").action((idOrName) => {
|
|
|
5523
6024
|
store.close();
|
|
5524
6025
|
}
|
|
5525
6026
|
});
|
|
5526
|
-
program.command("list").alias("ls").option("--status <status>", "filter by status").action((opts) => {
|
|
6027
|
+
program.command("list").alias("ls").option("--status <status>", "filter by status").option("--archived", "show only archived loops").option("--all", "include archived loops").action((opts) => {
|
|
6028
|
+
if (opts.archived && opts.all)
|
|
6029
|
+
throw new Error("use either --archived or --all, not both");
|
|
5527
6030
|
const store = new Store;
|
|
5528
6031
|
try {
|
|
5529
|
-
const loops = store.listLoops({ status: opts.status });
|
|
6032
|
+
const loops = store.listLoops({ status: opts.status, archived: opts.archived, includeArchived: opts.all });
|
|
5530
6033
|
if (isJson())
|
|
5531
6034
|
print(loops.map(publicLoop));
|
|
5532
6035
|
else {
|
|
5533
6036
|
for (const loop of loops) {
|
|
5534
6037
|
const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
|
|
5535
|
-
|
|
6038
|
+
const archive = loop.archivedAt ? ` archived=${loop.archivedAt} from=${loop.archivedFromStatus ?? "-"}` : "";
|
|
6039
|
+
console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}${archive}`);
|
|
5536
6040
|
}
|
|
5537
6041
|
}
|
|
5538
6042
|
} finally {
|
|
@@ -5565,6 +6069,44 @@ program.command("runs [idOrName]").option("--limit <n>", "limit", "50").option("
|
|
|
5565
6069
|
store.close();
|
|
5566
6070
|
}
|
|
5567
6071
|
});
|
|
6072
|
+
program.command("expectations [idOrName]").description("evaluate deterministic loop expectations without mutating external task systems").option("--limit <n>", "maximum loops to inspect when no loop is specified", "200").option("--json", "print JSON").action((idOrName, opts) => {
|
|
6073
|
+
const store = new Store;
|
|
6074
|
+
try {
|
|
6075
|
+
const loops = idOrName ? [store.requireLoop(idOrName)] : store.listLoops({ limit: Number(opts.limit) });
|
|
6076
|
+
const values = loops.map((loop) => expectationForLoop(store, loop));
|
|
6077
|
+
if (isJson() || opts.json)
|
|
6078
|
+
console.log(JSON.stringify(idOrName ? values[0] : values, null, 2));
|
|
6079
|
+
else {
|
|
6080
|
+
for (const value of values) {
|
|
6081
|
+
console.log(`${value.ok ? "ok" : "fail"} ${value.loop.name} ${value.check.message}`);
|
|
6082
|
+
if (value.failure)
|
|
6083
|
+
console.log(` classification=${value.failure.classification} fingerprint=${value.failure.fingerprint}`);
|
|
6084
|
+
}
|
|
6085
|
+
}
|
|
6086
|
+
if (values.some((value) => !value.ok))
|
|
6087
|
+
process.exitCode = 1;
|
|
6088
|
+
} finally {
|
|
6089
|
+
store.close();
|
|
6090
|
+
}
|
|
6091
|
+
});
|
|
6092
|
+
program.command("health").description("summarize loop health and latest-run expectation status").option("--json", "print JSON").action((opts) => {
|
|
6093
|
+
const store = new Store;
|
|
6094
|
+
try {
|
|
6095
|
+
const report = buildHealthReport(store);
|
|
6096
|
+
if (isJson() || opts.json)
|
|
6097
|
+
console.log(JSON.stringify(report, null, 2));
|
|
6098
|
+
else {
|
|
6099
|
+
console.log(`loops=${report.summary.loops} healthy=${report.summary.healthy} unhealthy=${report.summary.unhealthy} warnings=${report.summary.warnings}`);
|
|
6100
|
+
for (const expectation of report.expectations.filter((entry) => !entry.ok)) {
|
|
6101
|
+
console.log(`fail ${expectation.loop.name} ${expectation.failure?.classification ?? "unknown"} ${expectation.failure?.fingerprint ?? "-"}`);
|
|
6102
|
+
}
|
|
6103
|
+
}
|
|
6104
|
+
if (!report.ok)
|
|
6105
|
+
process.exitCode = 1;
|
|
6106
|
+
} finally {
|
|
6107
|
+
store.close();
|
|
6108
|
+
}
|
|
6109
|
+
});
|
|
5568
6110
|
program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
|
|
5569
6111
|
program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
|
|
5570
6112
|
program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
|
|
@@ -5572,6 +6114,8 @@ function updateStatus(idOrName, status) {
|
|
|
5572
6114
|
const store = new Store;
|
|
5573
6115
|
try {
|
|
5574
6116
|
const loop = store.requireLoop(idOrName);
|
|
6117
|
+
if (loop.archivedAt)
|
|
6118
|
+
throw new Error(`loop is archived; run 'loops unarchive ${idOrName}' first`);
|
|
5575
6119
|
const updated = store.updateLoop(loop.id, { status, nextRunAt: status === "stopped" ? undefined : loop.nextRunAt });
|
|
5576
6120
|
print(publicLoop(updated), `${updated.id} ${updated.status}`);
|
|
5577
6121
|
} finally {
|
|
@@ -5587,10 +6131,30 @@ program.command("remove <idOrName>").alias("rm").action((idOrName) => {
|
|
|
5587
6131
|
store.close();
|
|
5588
6132
|
}
|
|
5589
6133
|
});
|
|
6134
|
+
program.command("archive <idOrName>").description("archive a loop without deleting history").action((idOrName) => {
|
|
6135
|
+
const store = new Store;
|
|
6136
|
+
try {
|
|
6137
|
+
const loop = store.archiveLoop(idOrName);
|
|
6138
|
+
print(publicLoop(loop), `${loop.id} archived`);
|
|
6139
|
+
} finally {
|
|
6140
|
+
store.close();
|
|
6141
|
+
}
|
|
6142
|
+
});
|
|
6143
|
+
program.command("unarchive <idOrName>").alias("restore").description("restore an archived loop").action((idOrName) => {
|
|
6144
|
+
const store = new Store;
|
|
6145
|
+
try {
|
|
6146
|
+
const loop = store.unarchiveLoop(idOrName);
|
|
6147
|
+
print(publicLoop(loop), `${loop.id} ${loop.status}`);
|
|
6148
|
+
} finally {
|
|
6149
|
+
store.close();
|
|
6150
|
+
}
|
|
6151
|
+
});
|
|
5590
6152
|
program.command("run-now <idOrName>").option("--show-output", "show stdout/stderr").action(async (idOrName, opts) => {
|
|
5591
6153
|
const store = new Store;
|
|
5592
6154
|
try {
|
|
5593
6155
|
const loop = store.requireLoop(idOrName);
|
|
6156
|
+
if (loop.archivedAt)
|
|
6157
|
+
throw new Error(`loop is archived; run 'loops unarchive ${idOrName}' before running it`);
|
|
5594
6158
|
const runnerId = `manual:${process.pid}`;
|
|
5595
6159
|
const now = new Date;
|
|
5596
6160
|
let scheduledFor = manualRunScheduledFor(loop, now);
|