@anvia/durable 0.0.0-bootstrap.0 → 0.1.1
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/LICENSE +21 -0
- package/README.md +161 -3
- package/dist/chunk-7WOOJGHA.js +499 -0
- package/dist/chunk-7WOOJGHA.js.map +1 -0
- package/dist/chunk-ETGANBD4.js +83 -0
- package/dist/chunk-ETGANBD4.js.map +1 -0
- package/dist/chunk-KIMHK6CE.js +353 -0
- package/dist/chunk-KIMHK6CE.js.map +1 -0
- package/dist/index.d.ts +182 -0
- package/dist/index.js +1930 -0
- package/dist/index.js.map +1 -0
- package/dist/maintenance.d.ts +10 -0
- package/dist/maintenance.js +119 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/protocol.d.ts +37 -0
- package/dist/protocol.js +213 -0
- package/dist/protocol.js.map +1 -0
- package/dist/sqlite.d.ts +40 -0
- package/dist/sqlite.js +8 -0
- package/dist/sqlite.js.map +1 -0
- package/dist/task-protocol-Cewz7y6v.d.ts +38 -0
- package/dist/types-CBjKEKQ7.d.ts +396 -0
- package/package.json +50 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anvia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,163 @@
|
|
|
1
|
-
# @anvia/durable
|
|
1
|
+
# @anvia/durable
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run Anvia agents with persisted model and tool checkpoints. Submit work, observe its
|
|
4
|
+
progress, and recover unfinished runs after a process restart.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Experimental: this first version supports one runtime owner per local SQLite database,
|
|
7
|
+
static local tools, and committed progress events. It does not stream individual tokens.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add @anvia/durable @anvia/core @anvia/openai zod
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
SQLite requires Node.js 22.16 or newer. Use the accompanying core release with the
|
|
16
|
+
durable execution protocol; older core versions are rejected when importing this package.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { Agent } from "@anvia/core/agent";
|
|
22
|
+
import { DurableRuntime } from "@anvia/durable";
|
|
23
|
+
import { SqliteDurableStore } from "@anvia/durable/sqlite";
|
|
24
|
+
import { OpenAIClient } from "@anvia/openai";
|
|
25
|
+
|
|
26
|
+
const client = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY! });
|
|
27
|
+
const researcher = new Agent({
|
|
28
|
+
id: "researcher",
|
|
29
|
+
model: client.completionModel({ modelId: "gpt-5.6", api: "responses" }),
|
|
30
|
+
instructions: "Produce a concise research brief from the supplied material.",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const runtime = await DurableRuntime.open({
|
|
34
|
+
store: new SqliteDurableStore("./anvia-runs.sqlite"),
|
|
35
|
+
agents: [{ agent: researcher, version: "1" }],
|
|
36
|
+
maxConcurrentRuns: 4,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await runtime.resume(); // discover unfinished work from the previous process
|
|
41
|
+
const run = await runtime.submit({
|
|
42
|
+
agentId: researcher.id,
|
|
43
|
+
sessionId: "research-session",
|
|
44
|
+
requestId: "research-job-42",
|
|
45
|
+
prompt: "Summarize these research notes: ...",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
for await (const event of run.stream()) {
|
|
49
|
+
console.log(event.type, event.data);
|
|
50
|
+
}
|
|
51
|
+
const outcome = await run.result();
|
|
52
|
+
if (outcome.type === "response") console.log(outcome.output);
|
|
53
|
+
} finally {
|
|
54
|
+
await runtime.close();
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Keep the runtime alive at application scope in a server. A client disconnect should close
|
|
59
|
+
only that client's subscription. Call `run.cancel()` to explicitly cancel the work.
|
|
60
|
+
|
|
61
|
+
## What survives a restart
|
|
62
|
+
|
|
63
|
+
- Submissions, deduplicated by `(sessionId, requestId)`; changed content with the same ID is rejected.
|
|
64
|
+
- Completed normalized model responses and tool results, reused during recovery.
|
|
65
|
+
- Pending approvals and questions, with responses accepted once per interaction ID.
|
|
66
|
+
- Session history, operation IDs, model-turn counts, usage, and progress-event cursors.
|
|
67
|
+
- Explicit cancellations, which are never restarted automatically.
|
|
68
|
+
|
|
69
|
+
Interrupted model requests may be sent again. A provider may bill both attempts; usage
|
|
70
|
+
records only completed responses received and committed by this runtime.
|
|
71
|
+
|
|
72
|
+
Tools default to `manual` recovery. Mark read-only tools `safe`, or use `idempotent` only
|
|
73
|
+
when the external service deduplicates requests using `context.operationId`:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const runtime = await DurableRuntime.open({
|
|
77
|
+
store: new SqliteDurableStore("./anvia-runs.sqlite"),
|
|
78
|
+
agents: [
|
|
79
|
+
{
|
|
80
|
+
agent: researcher,
|
|
81
|
+
version: "1",
|
|
82
|
+
toolRecovery: { search: "safe", createTicket: "idempotent" },
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The tools in `toolRecovery` must be registered on that agent. A committed tool result is
|
|
89
|
+
never executed again, regardless of policy. An interrupted `manual` tool becomes
|
|
90
|
+
`needs_attention`; inspect the external system and supply its actual result with
|
|
91
|
+
`run.resolveTool(operationId, output)`. Merely selecting `idempotent` does not make an API safe.
|
|
92
|
+
|
|
93
|
+
## Queues, inspection, and HTTP
|
|
94
|
+
|
|
95
|
+
Use `runtime.listRuns({ sessionId, status: "waiting", limit: 25 })` to discover pending
|
|
96
|
+
approvals. Listings also support agent/status filters and insertion cursors. Inspect the
|
|
97
|
+
full record with `runtime.getRun(id)` and `run.snapshot()`.
|
|
98
|
+
|
|
99
|
+
`runtime.submit(input, { enqueue: true })` persists a successor behind the current session
|
|
100
|
+
run. Different sessions execute up to `maxConcurrentRuns` (default 4). Approvals and recovery
|
|
101
|
+
blocks pause their session queue while freeing capacity for other sessions.
|
|
102
|
+
|
|
103
|
+
Opt into persisted model backoff on a registration:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
{ agent: researcher, version: "1", modelRetry: {
|
|
107
|
+
maxAttempts: 3, initialDelayMs: 1000, maxDelayMs: 30_000,
|
|
108
|
+
} }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The policy applies to core completion-attempt errors, including provider errors, completion
|
|
112
|
+
validation, and completion observers. Attempt counts include interrupted
|
|
113
|
+
requests; retry deadlines survive restart. Tool recovery policies remain separate.
|
|
114
|
+
|
|
115
|
+
`@anvia/server/durable` provides an authorized Fetch handler, and `@anvia/client/durable`
|
|
116
|
+
provides a browser-safe HTTP/SSE client. Reconnect using an atomic snapshot and its event
|
|
117
|
+
cursor. See the [HTTP guide](../../docs/packages/durable.md#http-server-and-client).
|
|
118
|
+
|
|
119
|
+
## Task graphs
|
|
120
|
+
|
|
121
|
+
`runtime.submitGraph({ sessionId, requestId, tasks })` creates a persisted static DAG.
|
|
122
|
+
Each task specifies `id`, `agentId`, `prompt`, and optional `dependsOn` task IDs. Independent
|
|
123
|
+
nodes run concurrently; a dependent starts only after all prerequisites return successfully.
|
|
124
|
+
Their committed outputs are supplied as JSON data in the dependent's input.
|
|
125
|
+
|
|
126
|
+
`graph.snapshot()` exposes nodes, edges, outputs, interactions, waiting reasons, and an event
|
|
127
|
+
cursor. `graph.stream()` emits committed task events; refresh the snapshot for live graph
|
|
128
|
+
state. Use each node's `runId` for existing approval, retry, and reconciliation methods.
|
|
129
|
+
`runtime.resume()` recovers eligible graph nodes after restart. Approvals and uncertain
|
|
130
|
+
external effects still require explicit decisions.
|
|
131
|
+
|
|
132
|
+
The HTTP client exposes `submitGraph`, `graphSnapshot`, `listGraphs`, `streamGraph`, and
|
|
133
|
+
`cancelGraph`. See the [task graph guide](../../docs/packages/durable.md#task-dependencies-and-exposed-graphs).
|
|
134
|
+
This version exposes graph data and execution;
|
|
135
|
+
Studio visualization and durable core Pipeline execution remain future work.
|
|
136
|
+
|
|
137
|
+
## Custom tasks and dynamic ownership
|
|
138
|
+
|
|
139
|
+
Use `defineTask()` for schema-validated input, checkpoints, and output. Register definitions
|
|
140
|
+
in `DurableRuntime.open({ store, tasks, agents })`, then call `runtime.submitTask()`.
|
|
141
|
+
A phase can spawn custom or agent children, persist a child join, wait for a timer or named
|
|
142
|
+
signal, and journal external effects with explicit recovery policies. Waiting phases release
|
|
143
|
+
capacity; cancelling a tree fences new work and waits for owned callbacks to settle.
|
|
144
|
+
|
|
145
|
+
`task.graph()` exposes the complete bounded ownership tree and current waits. `task.stream()`
|
|
146
|
+
emits committed tree changes. The HTTP/client integration also exposes custom-task submission,
|
|
147
|
+
signals, reconciliation, cancellation, snapshots, and graph/event reads. See the
|
|
148
|
+
[custom task guide](../../docs/packages/durable-tasks.md) for contracts, examples, and limits.
|
|
149
|
+
|
|
150
|
+
## Operational controls
|
|
151
|
+
|
|
152
|
+
Use `runtime.health()` for readiness, `runtime.metrics()` for aggregate status counts, and
|
|
153
|
+
`onFatalError` to notify your supervisor. Storage failures stop both schedulers and reject
|
|
154
|
+
new work. Configurable pending-work, payload, and operation limits bound admission.
|
|
155
|
+
|
|
156
|
+
`@anvia/durable/maintenance` provides offline `backupSqlite` and `restoreSqlite`; both write
|
|
157
|
+
new files only. See the [operations guide](../../docs/packages/durable-operations.md) for
|
|
158
|
+
restore, upgrade, rollback, and rollout procedures. The package remains experimental.
|
|
159
|
+
|
|
160
|
+
## Learn more
|
|
161
|
+
|
|
162
|
+
- [Execution, recovery, and API guide](../../docs/packages/durable.md)
|
|
163
|
+
- [Anvia overview](../../README.md)
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import {
|
|
2
|
+
eventTypeSchema,
|
|
3
|
+
graphListSchema,
|
|
4
|
+
json,
|
|
5
|
+
listOptionsSchema,
|
|
6
|
+
nonblank,
|
|
7
|
+
parseGraphRecord,
|
|
8
|
+
parseOperation,
|
|
9
|
+
parseRun,
|
|
10
|
+
parseTask,
|
|
11
|
+
taskListSchema
|
|
12
|
+
} from "./chunk-KIMHK6CE.js";
|
|
13
|
+
|
|
14
|
+
// src/metrics.ts
|
|
15
|
+
function sqliteMetrics(db) {
|
|
16
|
+
const statuses = (table) => Object.fromEntries(
|
|
17
|
+
db.prepare(`SELECT status, COUNT(*) AS n FROM ${table} GROUP BY status`).all().map((row) => [String(row.status), Number(row.n)])
|
|
18
|
+
);
|
|
19
|
+
const oldest = db.prepare(`SELECT MIN(created) AS oldest FROM (
|
|
20
|
+
SELECT MIN(json_extract(record, '$.createdAt')) AS created FROM anvia_durable_runs WHERE status NOT IN ('completed', 'failed', 'cancelled')
|
|
21
|
+
UNION ALL SELECT MIN(json_extract(record, '$.createdAt')) FROM anvia_durable_tasks WHERE status NOT IN ('completed', 'failed', 'cancelled')
|
|
22
|
+
)`).get().oldest;
|
|
23
|
+
const count = (table) => Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
|
|
24
|
+
return {
|
|
25
|
+
observedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
26
|
+
runs: statuses("anvia_durable_runs"),
|
|
27
|
+
tasks: statuses("anvia_durable_tasks"),
|
|
28
|
+
oldestPendingAgeMs: typeof oldest === "string" ? Math.max(0, Date.now() - Date.parse(oldest)) : 0,
|
|
29
|
+
operations: count("anvia_durable_operations"),
|
|
30
|
+
events: count("anvia_durable_events"),
|
|
31
|
+
databaseBytes: Number(db.prepare("PRAGMA page_count").get().page_count) * Number(db.prepare("PRAGMA page_size").get().page_size)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/tasks/sqlite.ts
|
|
36
|
+
var taskTables = `
|
|
37
|
+
CREATE TABLE IF NOT EXISTS anvia_durable_tasks (
|
|
38
|
+
id TEXT PRIMARY KEY, root_id TEXT NOT NULL, parent_id TEXT NOT NULL,
|
|
39
|
+
session_id TEXT NOT NULL, key TEXT NOT NULL, status TEXT NOT NULL, ready_order INTEGER NOT NULL, record TEXT NOT NULL,
|
|
40
|
+
UNIQUE(session_id, parent_id, key)
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS anvia_durable_task_root ON anvia_durable_tasks(root_id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS anvia_durable_task_parent ON anvia_durable_tasks(parent_id);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS anvia_durable_task_status ON anvia_durable_tasks(status);
|
|
45
|
+
`;
|
|
46
|
+
function taskTransaction(db, assertActive) {
|
|
47
|
+
const read = (sql, ...parameters) => {
|
|
48
|
+
assertActive();
|
|
49
|
+
return db.prepare(sql).all(...parameters).map((row) => parseTask(JSON.parse(String(row.record))));
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
getTask: (id) => read("SELECT record FROM anvia_durable_tasks WHERE id = ?", id)[0],
|
|
53
|
+
findTask: (session, parent, key) => read(
|
|
54
|
+
"SELECT record FROM anvia_durable_tasks WHERE session_id = ? AND parent_id = ? AND key = ?",
|
|
55
|
+
session,
|
|
56
|
+
parent ?? "",
|
|
57
|
+
key
|
|
58
|
+
)[0],
|
|
59
|
+
taskChildren: (id) => read("SELECT record FROM anvia_durable_tasks WHERE parent_id = ? ORDER BY rowid", id),
|
|
60
|
+
taskTree: (root) => read("SELECT record FROM anvia_durable_tasks WHERE root_id = ? ORDER BY rowid", root),
|
|
61
|
+
putTask: (value) => {
|
|
62
|
+
assertActive();
|
|
63
|
+
const task = parseTask(value);
|
|
64
|
+
db.prepare(`INSERT INTO anvia_durable_tasks(id, root_id, parent_id, session_id, key, status, record, ready_order)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(sequence), 0) + 1 FROM anvia_durable_events))
|
|
66
|
+
ON CONFLICT(id) DO UPDATE SET status = excluded.status, record = excluded.record, ready_order = excluded.ready_order`).run(
|
|
67
|
+
task.id,
|
|
68
|
+
task.rootId,
|
|
69
|
+
task.parentId ?? "",
|
|
70
|
+
task.sessionId,
|
|
71
|
+
task.key,
|
|
72
|
+
task.status,
|
|
73
|
+
JSON.stringify(task)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function listTasks(db, input) {
|
|
79
|
+
const options = taskListSchema.parse(input);
|
|
80
|
+
const limit = options.limit ?? 50;
|
|
81
|
+
const rows = db.prepare(`SELECT rowid AS cursor, record FROM anvia_durable_tasks WHERE parent_id = ''
|
|
82
|
+
AND rowid > ? ${options.sessionId === void 0 ? "" : "AND session_id = ?"} ORDER BY rowid LIMIT ?`).all(
|
|
83
|
+
options.after ?? 0,
|
|
84
|
+
...options.sessionId === void 0 ? [] : [options.sessionId],
|
|
85
|
+
limit + 1
|
|
86
|
+
);
|
|
87
|
+
const page = rows.slice(0, limit);
|
|
88
|
+
return {
|
|
89
|
+
tasks: page.map((row) => parseTask(JSON.parse(String(row.record)))),
|
|
90
|
+
...rows.length > limit ? { nextCursor: Number(page.at(-1).cursor) } : {}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function scheduleTasks(db, excluded, limit) {
|
|
94
|
+
const filter = excluded.length === 0 ? "" : `AND id NOT IN (${excluded.map(() => "?").join(",")})`;
|
|
95
|
+
return db.prepare(
|
|
96
|
+
`SELECT id FROM anvia_durable_tasks WHERE status IN ('pending', 'running') ${filter} ORDER BY ready_order, rowid LIMIT ?`
|
|
97
|
+
).all(...excluded, limit).map((row) => String(row.id));
|
|
98
|
+
}
|
|
99
|
+
function unsettledTaskRoots(db, after) {
|
|
100
|
+
const rows = db.prepare(`SELECT rowid AS cursor, id FROM anvia_durable_tasks
|
|
101
|
+
WHERE parent_id = '' AND status NOT IN ('completed', 'failed', 'cancelled') AND rowid > ? ORDER BY rowid LIMIT 100`).all(after);
|
|
102
|
+
return {
|
|
103
|
+
ids: rows.map((row) => String(row.id)),
|
|
104
|
+
...rows.length === 100 ? { cursor: Number(rows.at(-1).cursor) } : {}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/sqlite.ts
|
|
109
|
+
import { hostname } from "node:os";
|
|
110
|
+
import { DatabaseSync } from "node:sqlite";
|
|
111
|
+
var SqliteDurableStore = class {
|
|
112
|
+
database;
|
|
113
|
+
token = globalThis.crypto.randomUUID();
|
|
114
|
+
acquired = false;
|
|
115
|
+
closed = false;
|
|
116
|
+
inTransaction = false;
|
|
117
|
+
constructor(path) {
|
|
118
|
+
nonblank(path, "SQLite path");
|
|
119
|
+
this.database = new DatabaseSync(path);
|
|
120
|
+
try {
|
|
121
|
+
if (this.database.prepare("SELECT 1 FROM sqlite_master WHERE name = 'anvia_durable_backup'").get() !== void 0)
|
|
122
|
+
throw new Error("Sealed durable backup; restore it into a new database before opening.");
|
|
123
|
+
this.database.exec(`
|
|
124
|
+
PRAGMA busy_timeout = 5000;
|
|
125
|
+
PRAGMA journal_mode = WAL;
|
|
126
|
+
PRAGMA synchronous = FULL;
|
|
127
|
+
CREATE TABLE IF NOT EXISTS anvia_durable_owner (
|
|
128
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
129
|
+
version INTEGER NOT NULL, token TEXT, pid INTEGER, host TEXT
|
|
130
|
+
);
|
|
131
|
+
INSERT OR IGNORE INTO anvia_durable_owner(singleton, version) VALUES (1, 3);
|
|
132
|
+
CREATE TABLE IF NOT EXISTS anvia_durable_runs (
|
|
133
|
+
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, request_id TEXT NOT NULL,
|
|
134
|
+
status TEXT NOT NULL, record TEXT NOT NULL,
|
|
135
|
+
UNIQUE(session_id, request_id)
|
|
136
|
+
);
|
|
137
|
+
CREATE INDEX IF NOT EXISTS anvia_durable_run_session ON anvia_durable_runs(session_id, status);
|
|
138
|
+
CREATE TABLE IF NOT EXISTS anvia_durable_graphs (
|
|
139
|
+
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, request_id TEXT NOT NULL, record TEXT NOT NULL,
|
|
140
|
+
UNIQUE(session_id, request_id)
|
|
141
|
+
);
|
|
142
|
+
CREATE INDEX IF NOT EXISTS anvia_durable_run_graph ON anvia_durable_runs(json_extract(record, '$.graphId'));
|
|
143
|
+
CREATE TABLE IF NOT EXISTS anvia_durable_operations (
|
|
144
|
+
run_id TEXT NOT NULL, key TEXT NOT NULL, record TEXT NOT NULL,
|
|
145
|
+
PRIMARY KEY(run_id, key)
|
|
146
|
+
);
|
|
147
|
+
CREATE TABLE IF NOT EXISTS anvia_durable_events (
|
|
148
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
149
|
+
run_id TEXT NOT NULL, created_at TEXT NOT NULL, type TEXT NOT NULL, data TEXT NOT NULL
|
|
150
|
+
);
|
|
151
|
+
CREATE INDEX IF NOT EXISTS anvia_durable_event_run ON anvia_durable_events(run_id, sequence);
|
|
152
|
+
${taskTables}
|
|
153
|
+
`);
|
|
154
|
+
const version = this.database.prepare("SELECT version FROM anvia_durable_owner WHERE singleton = 1").get();
|
|
155
|
+
if (version?.version !== 1 && version?.version !== 2 && version?.version !== 3)
|
|
156
|
+
throw new Error("Unsupported durable database schema version.");
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.database.close();
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
acquire() {
|
|
163
|
+
if (this.closed || this.acquired)
|
|
164
|
+
throw new Error("Durable store is closed or already acquired.");
|
|
165
|
+
this.atomic(() => {
|
|
166
|
+
const owner = this.database.prepare("SELECT token, pid, host FROM anvia_durable_owner WHERE singleton = 1").get();
|
|
167
|
+
if (owner.token !== null && (owner.host !== hostname() || processExists(Number(owner.pid)))) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
"Durable store already has a live owner. Only one runtime per database is supported."
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
this.database.prepare(
|
|
173
|
+
"UPDATE anvia_durable_owner SET token = ?, pid = ?, host = ?, version = 3 WHERE singleton = 1"
|
|
174
|
+
).run(this.token, process.pid, hostname());
|
|
175
|
+
});
|
|
176
|
+
this.acquired = true;
|
|
177
|
+
}
|
|
178
|
+
close() {
|
|
179
|
+
if (this.closed) return;
|
|
180
|
+
try {
|
|
181
|
+
if (this.acquired) {
|
|
182
|
+
this.atomic(() => {
|
|
183
|
+
this.database.prepare(
|
|
184
|
+
"UPDATE anvia_durable_owner SET token = NULL, pid = NULL, host = NULL WHERE singleton = 1 AND token = ?"
|
|
185
|
+
).run(this.token);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
this.database.close();
|
|
190
|
+
this.closed = true;
|
|
191
|
+
this.acquired = false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
transaction(callback) {
|
|
195
|
+
this.assertOpen();
|
|
196
|
+
return this.atomic(() => {
|
|
197
|
+
this.assertOwner();
|
|
198
|
+
let active = true;
|
|
199
|
+
try {
|
|
200
|
+
return callback(
|
|
201
|
+
this.transactionApi(() => {
|
|
202
|
+
if (!active) throw new Error("Durable transaction has finished.");
|
|
203
|
+
})
|
|
204
|
+
);
|
|
205
|
+
} finally {
|
|
206
|
+
active = false;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
metrics() {
|
|
211
|
+
this.assertOpen();
|
|
212
|
+
return sqliteMetrics(this.database);
|
|
213
|
+
}
|
|
214
|
+
listGraphs(input) {
|
|
215
|
+
this.assertOpen();
|
|
216
|
+
const options = graphListSchema.parse(input);
|
|
217
|
+
const limit = options.limit ?? 50;
|
|
218
|
+
const filter = options.sessionId === void 0 ? "" : "AND session_id = ?";
|
|
219
|
+
const rows = this.database.prepare(`SELECT rowid AS cursor, id, session_id, request_id, json_extract(record, '$.createdAt') AS created_at
|
|
220
|
+
FROM anvia_durable_graphs WHERE rowid > ? ${filter} ORDER BY rowid LIMIT ?`).all(
|
|
221
|
+
options.after ?? 0,
|
|
222
|
+
...options.sessionId === void 0 ? [] : [options.sessionId],
|
|
223
|
+
limit + 1
|
|
224
|
+
);
|
|
225
|
+
const page = rows.slice(0, limit);
|
|
226
|
+
return {
|
|
227
|
+
graphs: page.map((row) => ({
|
|
228
|
+
id: String(row.id),
|
|
229
|
+
sessionId: String(row.session_id),
|
|
230
|
+
requestId: String(row.request_id),
|
|
231
|
+
createdAt: String(row.created_at)
|
|
232
|
+
})),
|
|
233
|
+
...rows.length > limit ? { nextCursor: Number(page.at(-1).cursor) } : {}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
listTasks(options) {
|
|
237
|
+
this.assertOpen();
|
|
238
|
+
return listTasks(this.database, options);
|
|
239
|
+
}
|
|
240
|
+
scheduleTasks(excluded, limit) {
|
|
241
|
+
this.assertOpen();
|
|
242
|
+
return scheduleTasks(this.database, excluded, limit);
|
|
243
|
+
}
|
|
244
|
+
unsettledTaskRoots(after) {
|
|
245
|
+
this.assertOpen();
|
|
246
|
+
return unsettledTaskRoots(this.database, after);
|
|
247
|
+
}
|
|
248
|
+
taskEvents(rootId, after, limit) {
|
|
249
|
+
this.assertOpen();
|
|
250
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1e3)
|
|
251
|
+
throw new TypeError("Invalid task event cursor or page size.");
|
|
252
|
+
return this.database.prepare(`SELECT e.* FROM anvia_durable_events e JOIN anvia_durable_tasks t ON t.id = e.run_id
|
|
253
|
+
WHERE t.root_id = ? AND e.sequence > ? ORDER BY e.sequence LIMIT ?`).all(rootId, after, limit).map((row) => ({
|
|
254
|
+
sequence: Number(row.sequence),
|
|
255
|
+
runId: String(row.run_id),
|
|
256
|
+
createdAt: String(row.created_at),
|
|
257
|
+
type: eventTypeSchema.parse(row.type),
|
|
258
|
+
data: json(JSON.parse(String(row.data)))
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
graphEvents(graphId, after, limit) {
|
|
262
|
+
this.assertOpen();
|
|
263
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1e3)
|
|
264
|
+
throw new TypeError("Invalid graph event cursor or page size.");
|
|
265
|
+
return this.database.prepare(`SELECT e.* FROM anvia_durable_events e JOIN anvia_durable_runs r ON r.id = e.run_id
|
|
266
|
+
WHERE json_extract(r.record, '$.graphId') = ? AND e.sequence > ? ORDER BY e.sequence LIMIT ?`).all(graphId, after, limit).map((row) => ({
|
|
267
|
+
sequence: Number(row.sequence),
|
|
268
|
+
runId: String(row.run_id),
|
|
269
|
+
createdAt: String(row.created_at),
|
|
270
|
+
type: eventTypeSchema.parse(row.type),
|
|
271
|
+
data: json(JSON.parse(String(row.data)))
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
list(input) {
|
|
275
|
+
this.assertOpen();
|
|
276
|
+
const options = listOptionsSchema.parse(input);
|
|
277
|
+
const conditions = ["rowid > ?"];
|
|
278
|
+
const parameters = [options.after ?? 0];
|
|
279
|
+
for (const [column, value] of [
|
|
280
|
+
["session_id", options.sessionId],
|
|
281
|
+
["json_extract(record, '$.agentId')", options.agentId],
|
|
282
|
+
["status", options.status]
|
|
283
|
+
]) {
|
|
284
|
+
if (value !== void 0) {
|
|
285
|
+
conditions.push(`${column} = ?`);
|
|
286
|
+
parameters.push(value);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const limit = options.limit ?? 50;
|
|
290
|
+
const rows = this.database.prepare(
|
|
291
|
+
`SELECT rowid AS cursor, record FROM anvia_durable_runs WHERE ${conditions.join(" AND ")} ORDER BY rowid LIMIT ?`
|
|
292
|
+
).all(...parameters, limit + 1);
|
|
293
|
+
const page = rows.slice(0, limit);
|
|
294
|
+
return {
|
|
295
|
+
runs: page.map((row) => {
|
|
296
|
+
const {
|
|
297
|
+
id,
|
|
298
|
+
agentId,
|
|
299
|
+
sessionId,
|
|
300
|
+
requestId,
|
|
301
|
+
status,
|
|
302
|
+
createdAt,
|
|
303
|
+
updatedAt,
|
|
304
|
+
error,
|
|
305
|
+
blockedOperation,
|
|
306
|
+
nextAttemptAt
|
|
307
|
+
} = parseRun(JSON.parse(String(row.record)));
|
|
308
|
+
return {
|
|
309
|
+
id,
|
|
310
|
+
agentId,
|
|
311
|
+
sessionId,
|
|
312
|
+
requestId,
|
|
313
|
+
status,
|
|
314
|
+
createdAt,
|
|
315
|
+
updatedAt,
|
|
316
|
+
...error === void 0 ? {} : { error },
|
|
317
|
+
...blockedOperation === void 0 ? {} : { blockedOperation },
|
|
318
|
+
...nextAttemptAt === void 0 ? {} : { nextAttemptAt }
|
|
319
|
+
};
|
|
320
|
+
}),
|
|
321
|
+
...rows.length > limit ? { nextCursor: Number(page.at(-1).cursor) } : {}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
schedule(now, limit, excludedSessions) {
|
|
325
|
+
this.assertOpen();
|
|
326
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError("Invalid scheduler limit.");
|
|
327
|
+
const exclude = excludedSessions.length === 0 ? "" : `AND r.session_id NOT IN (${excludedSessions.map(() => "?").join(",")})`;
|
|
328
|
+
const eligible = `FROM anvia_durable_runs r
|
|
329
|
+
WHERE r.status IN ('queued', 'pending', 'running', 'retry_wait') ${exclude}
|
|
330
|
+
AND NOT EXISTS (SELECT 1 FROM anvia_durable_runs previous
|
|
331
|
+
WHERE previous.session_id = r.session_id AND previous.rowid < r.rowid
|
|
332
|
+
AND previous.status NOT IN ('completed', 'failed', 'cancelled'))
|
|
333
|
+
AND NOT EXISTS (SELECT 1 FROM json_each(json_extract(r.record, '$.dependencies')) dependency
|
|
334
|
+
LEFT JOIN anvia_durable_runs prerequisite ON prerequisite.id = dependency.value
|
|
335
|
+
WHERE prerequisite.id IS NULL OR prerequisite.status != 'completed'
|
|
336
|
+
OR json_extract(prerequisite.record, '$.outcome.type') IS NOT 'response')`;
|
|
337
|
+
const ids = this.database.prepare(`SELECT r.id ${eligible}
|
|
338
|
+
AND (r.status != 'retry_wait' OR json_extract(r.record, '$.nextAttemptAt') <= ?)
|
|
339
|
+
ORDER BY r.rowid LIMIT ?`).all(...excludedSessions, now, limit).map((row) => String(row.id));
|
|
340
|
+
const wake = this.database.prepare(`SELECT MIN(json_extract(r.record, '$.nextAttemptAt')) AS deadline
|
|
341
|
+
${eligible} AND r.status = 'retry_wait' AND json_extract(r.record, '$.nextAttemptAt') > ?`).get(...excludedSessions, now);
|
|
342
|
+
return { ids, ...typeof wake?.deadline === "string" ? { nextAttemptAt: wake.deadline } : {} };
|
|
343
|
+
}
|
|
344
|
+
events(runId, after, limit) {
|
|
345
|
+
this.assertOpen();
|
|
346
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1e3) {
|
|
347
|
+
throw new TypeError("Invalid durable event cursor or page size.");
|
|
348
|
+
}
|
|
349
|
+
return this.database.prepare(
|
|
350
|
+
"SELECT * FROM anvia_durable_events WHERE run_id = ? AND sequence > ? ORDER BY sequence LIMIT ?"
|
|
351
|
+
).all(runId, after, limit).map((row) => ({
|
|
352
|
+
sequence: Number(row.sequence),
|
|
353
|
+
runId: String(row.run_id),
|
|
354
|
+
createdAt: String(row.created_at),
|
|
355
|
+
type: eventTypeSchema.parse(row.type),
|
|
356
|
+
data: json(JSON.parse(String(row.data)))
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
transactionApi(assertActive) {
|
|
360
|
+
const readRun = (sql, ...parameters) => {
|
|
361
|
+
assertActive();
|
|
362
|
+
const row = this.database.prepare(sql).get(...parameters);
|
|
363
|
+
return row === void 0 ? void 0 : parseRun(JSON.parse(String(row.record)));
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
...taskTransaction(this.database, assertActive),
|
|
367
|
+
pendingCounts: () => {
|
|
368
|
+
assertActive();
|
|
369
|
+
const count = (table) => Number(
|
|
370
|
+
this.database.prepare(
|
|
371
|
+
`SELECT COUNT(*) AS n FROM ${table} WHERE status NOT IN ('completed', 'failed', 'cancelled')`
|
|
372
|
+
).get().n
|
|
373
|
+
);
|
|
374
|
+
return { runs: count("anvia_durable_runs"), tasks: count("anvia_durable_tasks") };
|
|
375
|
+
},
|
|
376
|
+
operationCount: (id) => {
|
|
377
|
+
assertActive();
|
|
378
|
+
return Number(
|
|
379
|
+
this.database.prepare("SELECT COUNT(*) AS n FROM anvia_durable_operations WHERE run_id = ?").get(id).n
|
|
380
|
+
);
|
|
381
|
+
},
|
|
382
|
+
getGraph: (id) => {
|
|
383
|
+
assertActive();
|
|
384
|
+
const row = this.database.prepare("SELECT record FROM anvia_durable_graphs WHERE id = ?").get(id);
|
|
385
|
+
return row === void 0 ? void 0 : parseGraphRecord(JSON.parse(String(row.record)));
|
|
386
|
+
},
|
|
387
|
+
findGraphRequest: (session, request) => {
|
|
388
|
+
assertActive();
|
|
389
|
+
const row = this.database.prepare(
|
|
390
|
+
"SELECT record FROM anvia_durable_graphs WHERE session_id = ? AND request_id = ?"
|
|
391
|
+
).get(session, request);
|
|
392
|
+
return row === void 0 ? void 0 : parseGraphRecord(JSON.parse(String(row.record)));
|
|
393
|
+
},
|
|
394
|
+
putGraph: (graph) => {
|
|
395
|
+
assertActive();
|
|
396
|
+
this.database.prepare(`INSERT INTO anvia_durable_graphs(id, session_id, request_id, record) VALUES (?, ?, ?, ?)
|
|
397
|
+
ON CONFLICT(id) DO UPDATE SET record = excluded.record`).run(
|
|
398
|
+
graph.id,
|
|
399
|
+
graph.submission.sessionId,
|
|
400
|
+
graph.submission.requestId,
|
|
401
|
+
JSON.stringify(parseGraphRecord(json(graph)))
|
|
402
|
+
);
|
|
403
|
+
},
|
|
404
|
+
getRun: (id) => readRun("SELECT record FROM anvia_durable_runs WHERE id = ?", id),
|
|
405
|
+
findRequest: (session, request) => readRun(
|
|
406
|
+
"SELECT record FROM anvia_durable_runs WHERE session_id = ? AND request_id = ?",
|
|
407
|
+
session,
|
|
408
|
+
request
|
|
409
|
+
),
|
|
410
|
+
activeRun: (session) => readRun(
|
|
411
|
+
"SELECT record FROM anvia_durable_runs WHERE session_id = ? AND status NOT IN ('completed', 'failed', 'cancelled') LIMIT 1",
|
|
412
|
+
session
|
|
413
|
+
),
|
|
414
|
+
latestCompleted: (session) => readRun(
|
|
415
|
+
"SELECT record FROM anvia_durable_runs WHERE session_id = ? AND status = 'completed' ORDER BY rowid DESC LIMIT 1",
|
|
416
|
+
session
|
|
417
|
+
),
|
|
418
|
+
hasLaterStartedRun: (id) => {
|
|
419
|
+
assertActive();
|
|
420
|
+
return this.database.prepare(`SELECT 1 FROM anvia_durable_runs later
|
|
421
|
+
JOIN anvia_durable_runs current ON later.session_id = current.session_id
|
|
422
|
+
WHERE current.id = ? AND later.rowid > current.rowid AND (later.status NOT IN ('queued', 'cancelled') OR json_extract(later.record, '$.startedAt') IS NOT NULL) LIMIT 1`).get(id) !== void 0;
|
|
423
|
+
},
|
|
424
|
+
putRun: (run) => {
|
|
425
|
+
assertActive();
|
|
426
|
+
const serialized = JSON.stringify(parseRun(json(run)));
|
|
427
|
+
this.database.prepare(`INSERT INTO anvia_durable_runs(id, session_id, request_id, status, record)
|
|
428
|
+
VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = excluded.status, record = excluded.record`).run(run.id, run.sessionId, run.requestId, run.status, serialized);
|
|
429
|
+
},
|
|
430
|
+
getOperation: (runId, key) => {
|
|
431
|
+
assertActive();
|
|
432
|
+
const row = this.database.prepare("SELECT record FROM anvia_durable_operations WHERE run_id = ? AND key = ?").get(runId, key);
|
|
433
|
+
return row === void 0 ? void 0 : parseOperation(JSON.parse(String(row.record)));
|
|
434
|
+
},
|
|
435
|
+
operations: (runId) => {
|
|
436
|
+
assertActive();
|
|
437
|
+
return this.database.prepare("SELECT record FROM anvia_durable_operations WHERE run_id = ? ORDER BY rowid").all(runId).map((row) => parseOperation(JSON.parse(String(row.record))));
|
|
438
|
+
},
|
|
439
|
+
putOperation: (runId, operation) => {
|
|
440
|
+
assertActive();
|
|
441
|
+
this.database.prepare(`INSERT INTO anvia_durable_operations(run_id, key, record) VALUES (?, ?, ?)
|
|
442
|
+
ON CONFLICT(run_id, key) DO UPDATE SET record = excluded.record`).run(runId, operation.key, JSON.stringify(parseOperation(json(operation))));
|
|
443
|
+
},
|
|
444
|
+
appendEvent: (runId, type, data) => {
|
|
445
|
+
assertActive();
|
|
446
|
+
this.database.prepare(
|
|
447
|
+
"INSERT INTO anvia_durable_events(run_id, created_at, type, data) VALUES (?, ?, ?, ?)"
|
|
448
|
+
).run(runId, (/* @__PURE__ */ new Date()).toISOString(), type, JSON.stringify(json(data)));
|
|
449
|
+
},
|
|
450
|
+
cursor: () => {
|
|
451
|
+
assertActive();
|
|
452
|
+
return Number(
|
|
453
|
+
this.database.prepare("SELECT COALESCE(MAX(sequence), 0) AS cursor FROM anvia_durable_events").get().cursor
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
atomic(callback) {
|
|
459
|
+
if (this.inTransaction) throw new Error("Nested durable transactions are not supported.");
|
|
460
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
461
|
+
this.inTransaction = true;
|
|
462
|
+
try {
|
|
463
|
+
const result = callback();
|
|
464
|
+
if (result !== null && typeof result === "object" && "then" in result) {
|
|
465
|
+
void Promise.resolve(result).catch(() => {
|
|
466
|
+
});
|
|
467
|
+
throw new TypeError("Durable transactions must be synchronous.");
|
|
468
|
+
}
|
|
469
|
+
this.database.exec("COMMIT");
|
|
470
|
+
return result;
|
|
471
|
+
} catch (error) {
|
|
472
|
+
this.database.exec("ROLLBACK");
|
|
473
|
+
throw error;
|
|
474
|
+
} finally {
|
|
475
|
+
this.inTransaction = false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
assertOpen() {
|
|
479
|
+
if (this.closed || !this.acquired) throw new Error("Durable store is not acquired.");
|
|
480
|
+
}
|
|
481
|
+
assertOwner() {
|
|
482
|
+
const owner = this.database.prepare("SELECT token FROM anvia_durable_owner WHERE singleton = 1").get();
|
|
483
|
+
if (owner?.token !== this.token) throw new Error("Durable store ownership was lost.");
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
function processExists(pid) {
|
|
487
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return true;
|
|
488
|
+
try {
|
|
489
|
+
process.kill(pid, 0);
|
|
490
|
+
return true;
|
|
491
|
+
} catch (error) {
|
|
492
|
+
return error.code !== "ESRCH";
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export {
|
|
497
|
+
SqliteDurableStore
|
|
498
|
+
};
|
|
499
|
+
//# sourceMappingURL=chunk-7WOOJGHA.js.map
|