@hasna/loops 0.4.12 → 0.4.14
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/CHANGELOG.md +106 -3
- package/README.md +70 -17
- package/dist/api/index.d.ts +35 -0
- package/dist/api/index.js +750 -10
- package/dist/cli/index.js +1744 -361
- package/dist/cli/ui.d.ts +44 -0
- package/dist/daemon/index.js +948 -212
- package/dist/generated/storage-kit/health.d.ts +19 -0
- package/dist/generated/storage-kit/index.d.ts +7 -0
- package/dist/generated/storage-kit/migrations.d.ts +47 -0
- package/dist/generated/storage-kit/mode.d.ts +47 -0
- package/dist/generated/storage-kit/pool.d.ts +33 -0
- package/dist/generated/storage-kit/query.d.ts +35 -0
- package/dist/generated/storage-kit/tls.d.ts +25 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6513 -4840
- package/dist/lib/health.d.ts +83 -3
- package/dist/lib/mode.d.ts +27 -0
- package/dist/lib/mode.js +69 -2
- package/dist/lib/scheduler.d.ts +5 -2
- package/dist/lib/storage/index.d.ts +1 -0
- package/dist/lib/storage/index.js +1329 -211
- package/dist/lib/storage/pg-executor.d.ts +27 -0
- package/dist/lib/storage/pg-runner-claim.d.ts +40 -0
- package/dist/lib/storage/postgres-loop-storage.d.ts +114 -0
- package/dist/lib/storage/sqlite.js +336 -8
- package/dist/lib/store.d.ts +234 -0
- package/dist/lib/store.js +350 -8
- package/dist/mcp/index.js +1447 -479
- package/dist/runner/index.js +179 -25
- package/dist/sdk/http.d.ts +115 -0
- package/dist/sdk/http.js +145 -0
- package/dist/sdk/index.d.ts +5 -1
- package/dist/sdk/index.js +1106 -296
- package/dist/serve/index.d.ts +4 -0
- package/dist/serve/index.js +7619 -0
- package/dist/types.d.ts +3 -0
- package/docs/CUTOVER-RUNBOOK.md +61 -0
- package/docs/DEPLOYMENT_MODES.md +23 -1
- package/docs/USAGE.md +66 -16
- package/package.json +14 -2
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PoolQueryClient } from "../../generated/storage-kit/query.js";
|
|
2
|
+
import type { PostgresQueryExecutor } from "./postgres.js";
|
|
3
|
+
export interface PgExecutorOptions {
|
|
4
|
+
connectionString: string;
|
|
5
|
+
max?: number;
|
|
6
|
+
applicationName?: string;
|
|
7
|
+
connectionTimeoutMillis?: number;
|
|
8
|
+
idleTimeoutMillis?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* `PostgresQueryExecutor` backed by a live `pg.Pool` via the vendored kit.
|
|
12
|
+
*
|
|
13
|
+
* Transaction support is intentionally omitted: `PostgresStorage.migrate`
|
|
14
|
+
* treats DDL as idempotent (`CREATE ... IF NOT EXISTS` + a checksum ledger), so
|
|
15
|
+
* a pooled sequential apply is correct and avoids binding every inner
|
|
16
|
+
* `execute` to a single dedicated client.
|
|
17
|
+
*/
|
|
18
|
+
export declare class PgPoolExecutor implements PostgresQueryExecutor {
|
|
19
|
+
private readonly client;
|
|
20
|
+
constructor(client: PoolQueryClient);
|
|
21
|
+
static fromConnectionString(options: PgExecutorOptions): PgPoolExecutor;
|
|
22
|
+
/** The underlying typed query client, for callers that need `get`/`one`/`transaction`. */
|
|
23
|
+
get queryClient(): PoolQueryClient;
|
|
24
|
+
query<T extends Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<T[]>;
|
|
25
|
+
execute(sql: string, params?: readonly unknown[]): Promise<void>;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PoolQueryClient } from "../../generated/storage-kit/query.js";
|
|
2
|
+
export interface ClaimedRunRow extends Record<string, unknown> {
|
|
3
|
+
id: string;
|
|
4
|
+
loop_id: string;
|
|
5
|
+
loop_name: string;
|
|
6
|
+
scheduled_for: string | Date;
|
|
7
|
+
attempt: number;
|
|
8
|
+
status: string;
|
|
9
|
+
claimed_by: string | null;
|
|
10
|
+
claim_token: string | null;
|
|
11
|
+
lease_expires_at: string | Date | null;
|
|
12
|
+
}
|
|
13
|
+
export interface ClaimNextRunOptions {
|
|
14
|
+
runnerId: string;
|
|
15
|
+
leaseMs: number;
|
|
16
|
+
claimToken: string;
|
|
17
|
+
/** Statuses a run may be in to be claimable. Defaults to queued + lease-expired running. */
|
|
18
|
+
now?: Date;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Atomically claim the next claimable run for `runnerId`.
|
|
22
|
+
*
|
|
23
|
+
* A run is claimable when it is `queued`, or `running` with an expired lease
|
|
24
|
+
* (crash recovery). Returns the claimed row, or `null` when nothing was
|
|
25
|
+
* available. Two concurrent callers are guaranteed to receive distinct rows
|
|
26
|
+
* (or one receives `null`) thanks to `FOR UPDATE SKIP LOCKED`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function claimNextRun(client: PoolQueryClient, options: ClaimNextRunOptions): Promise<ClaimedRunRow | null>;
|
|
29
|
+
/**
|
|
30
|
+
* Heartbeat a claimed run's lease. Returns true when the lease was extended
|
|
31
|
+
* (the caller still owns the run), false when ownership was lost (claim token
|
|
32
|
+
* mismatch, run finalized, or lease already stolen).
|
|
33
|
+
*/
|
|
34
|
+
export declare function heartbeatRunLease(client: PoolQueryClient, input: {
|
|
35
|
+
runId: string;
|
|
36
|
+
runnerId: string;
|
|
37
|
+
claimToken: string;
|
|
38
|
+
leaseMs: number;
|
|
39
|
+
now?: Date;
|
|
40
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { RecoverExpiredRunLeasesResult } from "../store.js";
|
|
2
|
+
import { Store } from "../store.js";
|
|
3
|
+
import type { PoolQueryClient } from "../../generated/storage-kit/query.js";
|
|
4
|
+
import type { LoopStorageContract, LoopStorageMethodName } from "./contract.js";
|
|
5
|
+
/** Thrown by store methods that are not yet ported to the Postgres backend. */
|
|
6
|
+
export declare class NotImplementedError extends Error {
|
|
7
|
+
readonly code = "not_implemented";
|
|
8
|
+
constructor(method: string);
|
|
9
|
+
}
|
|
10
|
+
type M<K extends LoopStorageMethodName> = Store[K] extends (...a: infer A) => infer R ? {
|
|
11
|
+
args: A;
|
|
12
|
+
result: R;
|
|
13
|
+
} : never;
|
|
14
|
+
export declare class PostgresLoopStorage implements LoopStorageContract {
|
|
15
|
+
private readonly client;
|
|
16
|
+
readonly backend = "postgres";
|
|
17
|
+
readonly supportsRemoteRunners = true;
|
|
18
|
+
constructor(client: PoolQueryClient);
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
private assertDaemonLeaseFence;
|
|
21
|
+
private loadLoop;
|
|
22
|
+
private loadRun;
|
|
23
|
+
private loadRunBySlot;
|
|
24
|
+
private loadDaemonLease;
|
|
25
|
+
private setWorkItemsForLoop;
|
|
26
|
+
private setWorkItemsForWorkflowRun;
|
|
27
|
+
private cascadeWorkItemsForLoopRun;
|
|
28
|
+
createLoop(...args: M<"createLoop">["args"]): Promise<M<"createLoop">["result"]>;
|
|
29
|
+
getLoop(...args: M<"getLoop">["args"]): Promise<M<"getLoop">["result"]>;
|
|
30
|
+
findLoopByName(...args: M<"findLoopByName">["args"]): Promise<M<"findLoopByName">["result"]>;
|
|
31
|
+
requireLoop(...args: M<"requireLoop">["args"]): Promise<M<"requireLoop">["result"]>;
|
|
32
|
+
listLoops(...args: M<"listLoops">["args"]): Promise<M<"listLoops">["result"]>;
|
|
33
|
+
dueLoops(...args: M<"dueLoops">["args"]): Promise<M<"dueLoops">["result"]>;
|
|
34
|
+
updateLoop(...args: M<"updateLoop">["args"]): Promise<M<"updateLoop">["result"]>;
|
|
35
|
+
renameLoop(...args: M<"renameLoop">["args"]): Promise<M<"renameLoop">["result"]>;
|
|
36
|
+
archiveLoop(...args: M<"archiveLoop">["args"]): Promise<M<"archiveLoop">["result"]>;
|
|
37
|
+
unarchiveLoop(...args: M<"unarchiveLoop">["args"]): Promise<M<"unarchiveLoop">["result"]>;
|
|
38
|
+
deleteLoop(...args: M<"deleteLoop">["args"]): Promise<M<"deleteLoop">["result"]>;
|
|
39
|
+
private requireLoopIn;
|
|
40
|
+
countLoops(...args: M<"countLoops">["args"]): Promise<M<"countLoops">["result"]>;
|
|
41
|
+
createSkippedRun(...args: M<"createSkippedRun">["args"]): Promise<M<"createSkippedRun">["result"]>;
|
|
42
|
+
getRun(...args: M<"getRun">["args"]): Promise<M<"getRun">["result"]>;
|
|
43
|
+
getRunBySlot(...args: M<"getRunBySlot">["args"]): Promise<M<"getRunBySlot">["result"]>;
|
|
44
|
+
/**
|
|
45
|
+
* Claim a specific loop slot for a runner.
|
|
46
|
+
*
|
|
47
|
+
* Divergence from the sqlite Store (documented, not accidental): the sqlite
|
|
48
|
+
* path also consults LOCAL process liveness (`isRecordedProcessAlive`,
|
|
49
|
+
* `hasLiveWorkflowStepProcesses`) before stealing an expired-lease run,
|
|
50
|
+
* because the daemon and the run's child process share a host. On the
|
|
51
|
+
* Postgres/remote backend the claiming runner may be a different machine than
|
|
52
|
+
* the one that holds the (possibly still-live) process, so local pid checks
|
|
53
|
+
* are meaningless. Ownership here is governed purely by lease expiry: an
|
|
54
|
+
* expired lease is reclaimable, a live lease is not. The lease/heartbeat
|
|
55
|
+
* contract (plus `FOR UPDATE` row locks) is the remote correctness boundary.
|
|
56
|
+
*/
|
|
57
|
+
claimRun(...args: M<"claimRun">["args"]): Promise<M<"claimRun">["result"]>;
|
|
58
|
+
finalizeRun(...args: M<"finalizeRun">["args"]): Promise<M<"finalizeRun">["result"]>;
|
|
59
|
+
heartbeatRunLease(...args: M<"heartbeatRunLease">["args"]): Promise<M<"heartbeatRunLease">["result"]>;
|
|
60
|
+
recordRunProcess(...args: M<"recordRunProcess">["args"]): Promise<M<"recordRunProcess">["result"]>;
|
|
61
|
+
listRuns(...args: M<"listRuns">["args"]): Promise<M<"listRuns">["result"]>;
|
|
62
|
+
countRuns(...args: M<"countRuns">["args"]): Promise<M<"countRuns">["result"]>;
|
|
63
|
+
recoverExpiredRunLeases(...args: M<"recoverExpiredRunLeases">["args"]): Promise<M<"recoverExpiredRunLeases">["result"]>;
|
|
64
|
+
/**
|
|
65
|
+
* Recover expired run leases. Divergence from sqlite (documented): the remote
|
|
66
|
+
* backend cannot inspect local process liveness, so an expired lease is always
|
|
67
|
+
* abandoned (never "deferred because the local process is still alive"). The
|
|
68
|
+
* `deferred` array is therefore always empty here. The generated-route
|
|
69
|
+
* workflow archival side effect is not ported (route automation is a TIER 2
|
|
70
|
+
* path); the core guarantee — no run stays `running` past its lease — holds.
|
|
71
|
+
*/
|
|
72
|
+
recoverExpiredRunLeasesDetailed(...args: M<"recoverExpiredRunLeasesDetailed">["args"]): Promise<RecoverExpiredRunLeasesResult>;
|
|
73
|
+
pruneHistory(...args: M<"pruneHistory">["args"]): Promise<M<"pruneHistory">["result"]>;
|
|
74
|
+
acquireDaemonLease(...args: M<"acquireDaemonLease">["args"]): Promise<M<"acquireDaemonLease">["result"]>;
|
|
75
|
+
heartbeatDaemonLease(...args: M<"heartbeatDaemonLease">["args"]): Promise<M<"heartbeatDaemonLease">["result"]>;
|
|
76
|
+
releaseDaemonLease(...args: M<"releaseDaemonLease">["args"]): Promise<M<"releaseDaemonLease">["result"]>;
|
|
77
|
+
getDaemonLease(...args: M<"getDaemonLease">["args"]): Promise<M<"getDaemonLease">["result"]>;
|
|
78
|
+
private loadWorkflow;
|
|
79
|
+
getWorkflow(...args: M<"getWorkflow">["args"]): Promise<M<"getWorkflow">["result"]>;
|
|
80
|
+
listWorkflows(...args: M<"listWorkflows">["args"]): Promise<M<"listWorkflows">["result"]>;
|
|
81
|
+
countWorkflows(...args: M<"countWorkflows">["args"]): Promise<M<"countWorkflows">["result"]>;
|
|
82
|
+
getWorkflowInvocation(...args: M<"getWorkflowInvocation">["args"]): Promise<M<"getWorkflowInvocation">["result"]>;
|
|
83
|
+
listWorkflowInvocations(...args: M<"listWorkflowInvocations">["args"]): Promise<M<"listWorkflowInvocations">["result"]>;
|
|
84
|
+
getWorkflowWorkItem(...args: M<"getWorkflowWorkItem">["args"]): Promise<M<"getWorkflowWorkItem">["result"]>;
|
|
85
|
+
listWorkflowWorkItems(...args: M<"listWorkflowWorkItems">["args"]): Promise<M<"listWorkflowWorkItems">["result"]>;
|
|
86
|
+
countActiveWorkflowWorkItems(...args: M<"countActiveWorkflowWorkItems">["args"]): Promise<M<"countActiveWorkflowWorkItems">["result"]>;
|
|
87
|
+
getWorkflowRun(...args: M<"getWorkflowRun">["args"]): Promise<M<"getWorkflowRun">["result"]>;
|
|
88
|
+
listWorkflowRuns(...args: M<"listWorkflowRuns">["args"]): Promise<M<"listWorkflowRuns">["result"]>;
|
|
89
|
+
listWorkflowStepRuns(...args: M<"listWorkflowStepRuns">["args"]): Promise<M<"listWorkflowStepRuns">["result"]>;
|
|
90
|
+
getWorkflowStepRun(...args: M<"getWorkflowStepRun">["args"]): Promise<M<"getWorkflowStepRun">["result"]>;
|
|
91
|
+
listWorkflowEvents(...args: M<"listWorkflowEvents">["args"]): Promise<M<"listWorkflowEvents">["result"]>;
|
|
92
|
+
getGoal(...args: M<"getGoal">["args"]): Promise<M<"getGoal">["result"]>;
|
|
93
|
+
listGoals(...args: M<"listGoals">["args"]): Promise<M<"listGoals">["result"]>;
|
|
94
|
+
listGoalPlanNodes(...args: M<"listGoalPlanNodes">["args"]): Promise<M<"listGoalPlanNodes">["result"]>;
|
|
95
|
+
listGoalRuns(...args: M<"listGoalRuns">["args"]): Promise<M<"listGoalRuns">["result"]>;
|
|
96
|
+
createWorkflow(): never;
|
|
97
|
+
archiveWorkflow(): never;
|
|
98
|
+
createWorkflowInvocation(): never;
|
|
99
|
+
upsertWorkflowWorkItem(): never;
|
|
100
|
+
admitWorkflowWorkItem(): never;
|
|
101
|
+
createGoal(): never;
|
|
102
|
+
createGoalPlanNodes(): never;
|
|
103
|
+
updateGoalStatus(): never;
|
|
104
|
+
updateGoalPlanNode(): never;
|
|
105
|
+
recordGoalEvent(): never;
|
|
106
|
+
createWorkflowRun(): never;
|
|
107
|
+
startWorkflowStepRun(): never;
|
|
108
|
+
recoverWorkflowRun(): never;
|
|
109
|
+
finalizeWorkflowStepRun(): never;
|
|
110
|
+
finalizeWorkflowRun(): never;
|
|
111
|
+
appendWorkflowEvent(): never;
|
|
112
|
+
}
|
|
113
|
+
export declare function createPostgresLoopStorage(client: PoolQueryClient): PostgresLoopStorage;
|
|
114
|
+
export {};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/lib/store.ts
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
|
-
import { chmodSync, existsSync, mkdirSync as mkdirSync3, mkdtempSync, rmSync as
|
|
5
|
-
import { tmpdir } from "os";
|
|
6
|
-
import { basename as basename2, dirname as dirname2, join as
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, rmSync as rmSync3 } from "fs";
|
|
5
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
6
|
+
import { basename as basename2, dirname as dirname2, join as join4 } from "path";
|
|
7
7
|
|
|
8
8
|
// src/lib/errors.ts
|
|
9
9
|
class CodedError extends Error {
|
|
@@ -1202,6 +1202,196 @@ function discardWorkflowRunManifest(staged) {
|
|
|
1202
1202
|
} catch {}
|
|
1203
1203
|
}
|
|
1204
1204
|
|
|
1205
|
+
// src/lib/route/todos-cli.ts
|
|
1206
|
+
import { closeSync, mkdtempSync, openSync, readFileSync as readFileSync3, rmSync as rmSync2 } from "fs";
|
|
1207
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1208
|
+
import { join as join3 } from "path";
|
|
1209
|
+
import { homedir as homedir2, tmpdir } from "os";
|
|
1210
|
+
|
|
1211
|
+
// src/lib/format.ts
|
|
1212
|
+
var TEXT_OUTPUT_LIMIT = 32 * 1024;
|
|
1213
|
+
var SENSITIVE_PAYLOAD_KEYS = new Set(["env", "error", "prompt", "reason", "stderr", "stdout"]);
|
|
1214
|
+
function redact(value, visible = 0) {
|
|
1215
|
+
if (!value)
|
|
1216
|
+
return value;
|
|
1217
|
+
if (value.length <= visible)
|
|
1218
|
+
return value;
|
|
1219
|
+
if (visible <= 0)
|
|
1220
|
+
return `[redacted ${value.length} chars]`;
|
|
1221
|
+
return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
|
|
1222
|
+
}
|
|
1223
|
+
function truncateTextOutput(value) {
|
|
1224
|
+
if (value.length <= TEXT_OUTPUT_LIMIT)
|
|
1225
|
+
return value;
|
|
1226
|
+
return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
|
|
1227
|
+
[truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
|
|
1228
|
+
}
|
|
1229
|
+
function redactSensitivePayload(value, key) {
|
|
1230
|
+
if (key && SENSITIVE_PAYLOAD_KEYS.has(key)) {
|
|
1231
|
+
if (typeof value === "string")
|
|
1232
|
+
return redact(value);
|
|
1233
|
+
if (value === undefined || value === null)
|
|
1234
|
+
return value;
|
|
1235
|
+
return "[redacted]";
|
|
1236
|
+
}
|
|
1237
|
+
if (Array.isArray(value))
|
|
1238
|
+
return value.map((item) => redactSensitivePayload(item));
|
|
1239
|
+
if (value && typeof value === "object") {
|
|
1240
|
+
return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactSensitivePayload(entryValue, entryKey)]));
|
|
1241
|
+
}
|
|
1242
|
+
return value;
|
|
1243
|
+
}
|
|
1244
|
+
function textOutputBlocks(value, opts = {}) {
|
|
1245
|
+
const indent = opts.indent ?? "";
|
|
1246
|
+
const nested = `${indent} `;
|
|
1247
|
+
const blocks = [];
|
|
1248
|
+
for (const [label, output] of [
|
|
1249
|
+
["stdout", value.stdout],
|
|
1250
|
+
["stderr", value.stderr]
|
|
1251
|
+
]) {
|
|
1252
|
+
if (!output)
|
|
1253
|
+
continue;
|
|
1254
|
+
blocks.push(`${indent}${label}:`);
|
|
1255
|
+
for (const line of truncateTextOutput(output).replace(/\s+$/, "").split(/\r?\n/)) {
|
|
1256
|
+
blocks.push(`${nested}${line}`);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return blocks;
|
|
1260
|
+
}
|
|
1261
|
+
function publicLoop(loop) {
|
|
1262
|
+
const target = loop.target.type === "command" ? { ...loop.target, env: loop.target.env ? "[redacted]" : undefined } : loop.target.type === "agent" ? { ...loop.target, prompt: redact(loop.target.prompt) } : loop.target;
|
|
1263
|
+
return {
|
|
1264
|
+
...loop,
|
|
1265
|
+
target
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
function publicRun(run, showOutput = false, opts = {}) {
|
|
1269
|
+
return {
|
|
1270
|
+
...run,
|
|
1271
|
+
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
1272
|
+
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
|
|
1273
|
+
error: opts.redactError ? redact(run.error) : run.error
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
function publicExecutorResult(result, showOutput = false) {
|
|
1277
|
+
return {
|
|
1278
|
+
...result,
|
|
1279
|
+
stdout: showOutput ? result.stdout : result.stdout ? `[redacted ${result.stdout.length} chars]` : undefined,
|
|
1280
|
+
stderr: showOutput ? result.stderr : result.stderr ? `[redacted ${result.stderr.length} chars]` : undefined,
|
|
1281
|
+
error: redact(result.error)
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
function publicWorkflow(workflow) {
|
|
1285
|
+
return {
|
|
1286
|
+
...workflow,
|
|
1287
|
+
steps: workflow.steps.map((step) => ({
|
|
1288
|
+
...step,
|
|
1289
|
+
target: step.target.type === "agent" ? { ...step.target, prompt: redact(step.target.prompt) } : step.target.type === "command" && step.target.env ? { ...step.target, env: "[redacted]" } : step.target
|
|
1290
|
+
}))
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
function publicWorkflowRun(run) {
|
|
1294
|
+
return { ...run, error: redact(run.error) };
|
|
1295
|
+
}
|
|
1296
|
+
function publicWorkflowInvocation(invocation) {
|
|
1297
|
+
return redactSensitivePayload(invocation);
|
|
1298
|
+
}
|
|
1299
|
+
function publicWorkflowWorkItem(item) {
|
|
1300
|
+
return { ...item, lastReason: redact(item.lastReason, 240) };
|
|
1301
|
+
}
|
|
1302
|
+
function publicWorkflowStepRun(run, showOutput = false) {
|
|
1303
|
+
return {
|
|
1304
|
+
...run,
|
|
1305
|
+
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
1306
|
+
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
|
|
1307
|
+
error: redact(run.error)
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
function publicWorkflowEvent(event) {
|
|
1311
|
+
return { ...event, payload: redactSensitivePayload(event.payload) };
|
|
1312
|
+
}
|
|
1313
|
+
function publicGoal(goal) {
|
|
1314
|
+
return {
|
|
1315
|
+
...goal,
|
|
1316
|
+
objective: redact(goal.objective, 120)
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
function publicGoalRun(run) {
|
|
1320
|
+
return {
|
|
1321
|
+
...run,
|
|
1322
|
+
evidence: redactSensitivePayload(run.evidence),
|
|
1323
|
+
rawResponse: redactSensitivePayload(run.rawResponse)
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// src/lib/route/todos-cli.ts
|
|
1328
|
+
function defaultLoopsProject() {
|
|
1329
|
+
return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME || homedir2()}/.hasna/loops`;
|
|
1330
|
+
}
|
|
1331
|
+
function runLocalCommand(command, args, opts = {}) {
|
|
1332
|
+
const result = spawnSync2(command, args, {
|
|
1333
|
+
input: opts.input,
|
|
1334
|
+
encoding: "utf8",
|
|
1335
|
+
timeout: opts.timeoutMs ?? 30000,
|
|
1336
|
+
maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
|
|
1337
|
+
env: process.env
|
|
1338
|
+
});
|
|
1339
|
+
return {
|
|
1340
|
+
ok: result.status === 0,
|
|
1341
|
+
status: result.status,
|
|
1342
|
+
stdout: result.stdout || "",
|
|
1343
|
+
stderr: result.stderr || "",
|
|
1344
|
+
error: result.error ? String(result.error.message || result.error) : ""
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
1348
|
+
const tempDir = mkdtempSync(join3(tmpdir(), "loops-command-output-"));
|
|
1349
|
+
const stdoutPath = join3(tempDir, "stdout");
|
|
1350
|
+
const stdoutFd = openSync(stdoutPath, "w");
|
|
1351
|
+
let result;
|
|
1352
|
+
try {
|
|
1353
|
+
result = spawnSync2(command, args, {
|
|
1354
|
+
input: opts.input,
|
|
1355
|
+
encoding: "utf8",
|
|
1356
|
+
timeout: opts.timeoutMs ?? 30000,
|
|
1357
|
+
maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
|
|
1358
|
+
env: process.env,
|
|
1359
|
+
stdio: ["pipe", stdoutFd, "pipe"]
|
|
1360
|
+
});
|
|
1361
|
+
} finally {
|
|
1362
|
+
closeSync(stdoutFd);
|
|
1363
|
+
}
|
|
1364
|
+
try {
|
|
1365
|
+
return {
|
|
1366
|
+
ok: result.status === 0,
|
|
1367
|
+
status: result.status,
|
|
1368
|
+
stdout: readFileSync3(stdoutPath, "utf8"),
|
|
1369
|
+
stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString() || "",
|
|
1370
|
+
error: result.error ? String(result.error.message || result.error) : ""
|
|
1371
|
+
};
|
|
1372
|
+
} finally {
|
|
1373
|
+
rmSync2(tempDir, { recursive: true, force: true });
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
function ensureTodosTaskList(project, slug, name, description) {
|
|
1377
|
+
runLocalCommand("todos", ["--project", project, "task-lists", "--add", name, "--slug", slug, "-d", description]);
|
|
1378
|
+
const list = runLocalCommand("todos", ["--project", project, "--json", "task-lists"]);
|
|
1379
|
+
if (!list.ok)
|
|
1380
|
+
throw new Error(list.stderr || list.error || "failed to list todos task lists");
|
|
1381
|
+
const values = JSON.parse(list.stdout || "[]");
|
|
1382
|
+
const found = values.find((entry) => entry.slug === slug);
|
|
1383
|
+
if (!found)
|
|
1384
|
+
throw new Error(`todos task list not found after ensure: ${slug}`);
|
|
1385
|
+
return found.id;
|
|
1386
|
+
}
|
|
1387
|
+
function todosMutationSummary(result) {
|
|
1388
|
+
return {
|
|
1389
|
+
ok: result.ok,
|
|
1390
|
+
status: result.status,
|
|
1391
|
+
error: result.ok ? undefined : redact(result.stderr || result.error || "todos mutation failed", 320)
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1205
1395
|
// src/lib/store.ts
|
|
1206
1396
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
1207
1397
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
@@ -1211,6 +1401,7 @@ var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "s
|
|
|
1211
1401
|
var PRUNE_BATCH_SIZE = 400;
|
|
1212
1402
|
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
|
|
1213
1403
|
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
1404
|
+
var TASK_LIFECYCLE_TEMPLATE_ID = "task-lifecycle";
|
|
1214
1405
|
function rowToLoop(row) {
|
|
1215
1406
|
return {
|
|
1216
1407
|
id: row.id,
|
|
@@ -1261,6 +1452,9 @@ function rowToRun(row) {
|
|
|
1261
1452
|
updatedAt: row.updated_at
|
|
1262
1453
|
};
|
|
1263
1454
|
}
|
|
1455
|
+
function latestRunTime(row) {
|
|
1456
|
+
return row.finished_at ?? row.started_at ?? row.created_at;
|
|
1457
|
+
}
|
|
1264
1458
|
function rowToWorkflow(row) {
|
|
1265
1459
|
return {
|
|
1266
1460
|
id: row.id,
|
|
@@ -1515,7 +1709,7 @@ class Store {
|
|
|
1515
1709
|
const file = path ?? dbPath();
|
|
1516
1710
|
if (file !== ":memory:")
|
|
1517
1711
|
ensurePrivateStorePath(file);
|
|
1518
|
-
this.rootDir = file === ":memory:" ?
|
|
1712
|
+
this.rootDir = file === ":memory:" ? mkdtempSync2(join4(tmpdir2(), "open-loops-store-")) : dirname2(file);
|
|
1519
1713
|
if (file === ":memory:")
|
|
1520
1714
|
this.memoryRootDir = this.rootDir;
|
|
1521
1715
|
this.db = new Database(file);
|
|
@@ -2013,7 +2207,32 @@ class Store {
|
|
|
2013
2207
|
} else {
|
|
2014
2208
|
rows = this.db.query("SELECT * FROM loops WHERE archived_at IS NULL ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
|
|
2015
2209
|
}
|
|
2016
|
-
return rows.map(rowToLoop);
|
|
2210
|
+
return this.withLatestRunSummaries(rows.map(rowToLoop));
|
|
2211
|
+
}
|
|
2212
|
+
withLatestRunSummaries(loops) {
|
|
2213
|
+
if (loops.length === 0)
|
|
2214
|
+
return loops;
|
|
2215
|
+
const placeholders = loops.map(() => "?").join(",");
|
|
2216
|
+
const rows = this.db.query(`SELECT loop_id, id, status, started_at, finished_at, created_at
|
|
2217
|
+
FROM (
|
|
2218
|
+
SELECT loop_id, id, status, started_at, finished_at, created_at,
|
|
2219
|
+
ROW_NUMBER() OVER (PARTITION BY loop_id ORDER BY created_at DESC, id DESC) AS rn
|
|
2220
|
+
FROM loop_runs
|
|
2221
|
+
WHERE loop_id IN (${placeholders})
|
|
2222
|
+
)
|
|
2223
|
+
WHERE rn = 1`).all(...loops.map((loop) => loop.id));
|
|
2224
|
+
const latestByLoopId = new Map(rows.map((row) => [row.loop_id, row]));
|
|
2225
|
+
return loops.map((loop) => {
|
|
2226
|
+
const latest = latestByLoopId.get(loop.id);
|
|
2227
|
+
if (!latest)
|
|
2228
|
+
return loop;
|
|
2229
|
+
return {
|
|
2230
|
+
...loop,
|
|
2231
|
+
latestRunId: latest.id,
|
|
2232
|
+
latestRunStatus: latest.status,
|
|
2233
|
+
lastRunAt: latestRunTime(latest)
|
|
2234
|
+
};
|
|
2235
|
+
});
|
|
2017
2236
|
}
|
|
2018
2237
|
dueLoops(now, limit = 500) {
|
|
2019
2238
|
const rows = this.db.query(`SELECT * FROM loops
|
|
@@ -2132,6 +2351,45 @@ class Store {
|
|
|
2132
2351
|
throw error;
|
|
2133
2352
|
}
|
|
2134
2353
|
}
|
|
2354
|
+
updateAgentLoopTimeout(idOrName, timeoutMs, opts = {}) {
|
|
2355
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
2356
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2357
|
+
try {
|
|
2358
|
+
const current = this.requireUniqueLoop(idOrName);
|
|
2359
|
+
if (current.archivedAt)
|
|
2360
|
+
throw new LoopArchivedError(current.name || current.id);
|
|
2361
|
+
if (current.target.type !== "agent")
|
|
2362
|
+
throw new Error(`loop is not an agent loop: ${idOrName}`);
|
|
2363
|
+
if (this.hasRunningRun(current.id))
|
|
2364
|
+
throw new Error(`refusing to update running loop: ${current.id}`);
|
|
2365
|
+
const target = { ...current.target, timeoutMs };
|
|
2366
|
+
if (timeoutMs === null && target.idleTimeoutMs !== undefined)
|
|
2367
|
+
delete target.idleTimeoutMs;
|
|
2368
|
+
const res = this.db.query(`UPDATE loops SET target_json=$target, updated_at=$updated
|
|
2369
|
+
WHERE id=$id
|
|
2370
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
2371
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
2372
|
+
))`).run({
|
|
2373
|
+
$id: current.id,
|
|
2374
|
+
$target: JSON.stringify(target),
|
|
2375
|
+
$updated: updated,
|
|
2376
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
2377
|
+
$now: updated
|
|
2378
|
+
});
|
|
2379
|
+
if (res.changes !== 1)
|
|
2380
|
+
throw new Error("daemon lease lost");
|
|
2381
|
+
this.db.exec("COMMIT");
|
|
2382
|
+
const after = this.getLoop(current.id);
|
|
2383
|
+
if (!after)
|
|
2384
|
+
throw new Error(`loop not found after timeout update: ${current.id}`);
|
|
2385
|
+
return after;
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
try {
|
|
2388
|
+
this.db.exec("ROLLBACK");
|
|
2389
|
+
} catch {}
|
|
2390
|
+
throw error;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2135
2393
|
createAndRetargetWorkflowLoop(idOrName, workflowInput, opts = {}) {
|
|
2136
2394
|
const normalized = normalizeCreateWorkflowInput(workflowInput);
|
|
2137
2395
|
const updated = (opts.now ?? new Date).toISOString();
|
|
@@ -2462,6 +2720,59 @@ class Store {
|
|
|
2462
2720
|
updated
|
|
2463
2721
|
});
|
|
2464
2722
|
}
|
|
2723
|
+
taskLifecycleTodosPointerContext(workflowRunId) {
|
|
2724
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
2725
|
+
if (!run || run.status !== "succeeded" || !run.invocationId || !run.workItemId || !run.manifestPath)
|
|
2726
|
+
return;
|
|
2727
|
+
const workItem = this.getWorkflowWorkItem(run.workItemId);
|
|
2728
|
+
if (!workItem || workItem.routeKey !== "todos-task")
|
|
2729
|
+
return;
|
|
2730
|
+
const invocation = this.getWorkflowInvocation(run.invocationId);
|
|
2731
|
+
if (!invocation || invocation.templateId !== TASK_LIFECYCLE_TEMPLATE_ID)
|
|
2732
|
+
return;
|
|
2733
|
+
const projectPath = workItem.projectKey ?? invocation.scope?.projectPath;
|
|
2734
|
+
const taskId = invocation.subjectRef.id ?? workItem.subjectRef;
|
|
2735
|
+
if (!projectPath || !taskId)
|
|
2736
|
+
return;
|
|
2737
|
+
return {
|
|
2738
|
+
projectPath,
|
|
2739
|
+
taskId,
|
|
2740
|
+
invocationId: invocation.id,
|
|
2741
|
+
workflowRunId: run.id,
|
|
2742
|
+
manifestPath: run.manifestPath
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
syncSuccessfulTaskLifecycleTodosPointers(workflowRunId) {
|
|
2746
|
+
const context = this.taskLifecycleTodosPointerContext(workflowRunId);
|
|
2747
|
+
if (!context)
|
|
2748
|
+
return;
|
|
2749
|
+
const result = runLocalCommand("todos", [
|
|
2750
|
+
"--project",
|
|
2751
|
+
context.projectPath,
|
|
2752
|
+
"task",
|
|
2753
|
+
"workflow-pointers",
|
|
2754
|
+
context.taskId,
|
|
2755
|
+
"--clear",
|
|
2756
|
+
"--invocation",
|
|
2757
|
+
context.invocationId,
|
|
2758
|
+
"--run",
|
|
2759
|
+
context.workflowRunId,
|
|
2760
|
+
"--manifest",
|
|
2761
|
+
context.manifestPath,
|
|
2762
|
+
"--state",
|
|
2763
|
+
"succeeded",
|
|
2764
|
+
"--actor",
|
|
2765
|
+
"openloops:task-lifecycle"
|
|
2766
|
+
]);
|
|
2767
|
+
this.appendWorkflowEvent(workflowRunId, result.ok ? "todos_workflow_pointers_synced" : "todos_workflow_pointers_sync_failed", undefined, {
|
|
2768
|
+
projectPath: context.projectPath,
|
|
2769
|
+
taskId: context.taskId,
|
|
2770
|
+
invocationId: context.invocationId,
|
|
2771
|
+
workflowRunId: context.workflowRunId,
|
|
2772
|
+
manifestPath: context.manifestPath,
|
|
2773
|
+
mutation: todosMutationSummary(result)
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2465
2776
|
createWorkflowInvocation(input) {
|
|
2466
2777
|
const now = nowIso();
|
|
2467
2778
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -3424,6 +3735,8 @@ class Store {
|
|
|
3424
3735
|
const run = this.getWorkflowRun(workflowRunId);
|
|
3425
3736
|
if (!run)
|
|
3426
3737
|
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
3738
|
+
if (changed && status === "succeeded")
|
|
3739
|
+
this.syncSuccessfulTaskLifecycleTodosPointers(workflowRunId);
|
|
3427
3740
|
return run;
|
|
3428
3741
|
}
|
|
3429
3742
|
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
@@ -3485,6 +3798,17 @@ class Store {
|
|
|
3485
3798
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND scheduled_for = ? AND status = 'running'").get(loopId, scheduledFor);
|
|
3486
3799
|
return (row?.count ?? 0) > 0;
|
|
3487
3800
|
}
|
|
3801
|
+
hasBlockingRunningRunForOtherSlot(loopId, scheduledFor, nowIso2) {
|
|
3802
|
+
const rows = this.db.query(`SELECT * FROM loop_runs
|
|
3803
|
+
WHERE loop_id = ? AND scheduled_for <> ? AND status = 'running'`).all(loopId, scheduledFor);
|
|
3804
|
+
return rows.some((row) => {
|
|
3805
|
+
if (!row.lease_expires_at || row.lease_expires_at > nowIso2)
|
|
3806
|
+
return true;
|
|
3807
|
+
if (isRecordedProcessAlive(row.pid, row.process_started_at))
|
|
3808
|
+
return true;
|
|
3809
|
+
return this.hasLiveWorkflowStepProcesses(row.id);
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
3488
3812
|
markRunPid(id, pid, claimedBy, opts = {}) {
|
|
3489
3813
|
const now = (opts.now ?? new Date).toISOString();
|
|
3490
3814
|
const startedMs = processStartTimeMs(pid);
|
|
@@ -3617,6 +3941,10 @@ class Store {
|
|
|
3617
3941
|
loop = currentLoop;
|
|
3618
3942
|
const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
|
|
3619
3943
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
3944
|
+
if (loop.overlap === "skip" && this.hasBlockingRunningRunForOtherSlot(loop.id, scheduledFor, startedAt)) {
|
|
3945
|
+
this.db.exec("COMMIT");
|
|
3946
|
+
return;
|
|
3947
|
+
}
|
|
3620
3948
|
if (existing) {
|
|
3621
3949
|
if (existing.status === "running") {
|
|
3622
3950
|
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && isRecordedProcessAlive(existing.pid, existing.processStartedAt)) {
|
|
@@ -4188,9 +4516,9 @@ class Store {
|
|
|
4188
4516
|
const runDir = dirname2(manifestPath);
|
|
4189
4517
|
try {
|
|
4190
4518
|
if (/^[0-9a-f]{12,64}$/.test(basename2(runDir))) {
|
|
4191
|
-
|
|
4519
|
+
rmSync3(runDir, { recursive: true, force: true });
|
|
4192
4520
|
} else {
|
|
4193
|
-
|
|
4521
|
+
rmSync3(manifestPath, { force: true });
|
|
4194
4522
|
}
|
|
4195
4523
|
} catch {}
|
|
4196
4524
|
}
|
|
@@ -4257,7 +4585,7 @@ class Store {
|
|
|
4257
4585
|
this.db.close();
|
|
4258
4586
|
if (this.memoryRootDir) {
|
|
4259
4587
|
try {
|
|
4260
|
-
|
|
4588
|
+
rmSync3(this.memoryRootDir, { recursive: true, force: true });
|
|
4261
4589
|
} catch {}
|
|
4262
4590
|
this.memoryRootDir = undefined;
|
|
4263
4591
|
}
|