@lensagent/core 0.1.0
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 +48 -0
- package/config/lens.example.jsonc +92 -0
- package/config/pair.example.json +3 -0
- package/dist/adapters/executor.d.ts +35 -0
- package/dist/adapters/executor.js +296 -0
- package/dist/adapters/lsp.d.ts +18 -0
- package/dist/adapters/lsp.js +218 -0
- package/dist/adapters/mcp.d.ts +32 -0
- package/dist/adapters/mcp.js +283 -0
- package/dist/adapters/revert.d.ts +12 -0
- package/dist/adapters/revert.js +155 -0
- package/dist/config/runtime.d.ts +55 -0
- package/dist/config/runtime.js +371 -0
- package/dist/config/schema.d.ts +292 -0
- package/dist/config/schema.js +177 -0
- package/dist/contracts/adapter.d.ts +28 -0
- package/dist/contracts/adapter.js +8 -0
- package/dist/contracts/capability.d.ts +41 -0
- package/dist/contracts/capability.js +1 -0
- package/dist/contracts/domain.d.ts +61 -0
- package/dist/contracts/domain.js +1 -0
- package/dist/contracts/index.d.ts +5 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/model.d.ts +77 -0
- package/dist/contracts/model.js +1 -0
- package/dist/contracts/ports.d.ts +14 -0
- package/dist/contracts/ports.js +14 -0
- package/dist/core/access.d.ts +24 -0
- package/dist/core/access.js +205 -0
- package/dist/core/codec.d.ts +33 -0
- package/dist/core/codec.js +105 -0
- package/dist/core/domain.d.ts +13 -0
- package/dist/core/domain.js +24 -0
- package/dist/core/host.d.ts +19 -0
- package/dist/core/host.js +249 -0
- package/dist/core/identity.d.ts +19 -0
- package/dist/core/identity.js +54 -0
- package/dist/entry/bun.d.ts +1 -0
- package/dist/entry/bun.js +2 -0
- package/dist/entry/node.d.ts +2 -0
- package/dist/entry/node.js +3 -0
- package/dist/entry/rebuild.d.ts +1 -0
- package/dist/entry/rebuild.js +47 -0
- package/dist/entry/run.d.ts +1 -0
- package/dist/entry/run.js +43 -0
- package/dist/execution/lsp-process.d.ts +14 -0
- package/dist/execution/lsp-process.js +234 -0
- package/dist/http/server.d.ts +34 -0
- package/dist/http/server.js +1332 -0
- package/dist/launch.d.ts +3 -0
- package/dist/launch.js +4 -0
- package/dist/runtime/agent.d.ts +35 -0
- package/dist/runtime/agent.js +853 -0
- package/dist/runtime/artifacts.d.ts +15 -0
- package/dist/runtime/artifacts.js +72 -0
- package/dist/runtime/cache.d.ts +20 -0
- package/dist/runtime/cache.js +44 -0
- package/dist/runtime/calls.d.ts +21 -0
- package/dist/runtime/calls.js +130 -0
- package/dist/runtime/capabilities.d.ts +58 -0
- package/dist/runtime/capabilities.js +196 -0
- package/dist/runtime/cleanup.d.ts +7 -0
- package/dist/runtime/cleanup.js +33 -0
- package/dist/runtime/context.d.ts +34 -0
- package/dist/runtime/context.js +169 -0
- package/dist/runtime/diagnostics.d.ts +8 -0
- package/dist/runtime/diagnostics.js +71 -0
- package/dist/runtime/events.d.ts +22 -0
- package/dist/runtime/events.js +131 -0
- package/dist/runtime/kernel-capabilities.d.ts +32 -0
- package/dist/runtime/kernel-capabilities.js +200 -0
- package/dist/runtime/manifests.d.ts +14 -0
- package/dist/runtime/manifests.js +117 -0
- package/dist/runtime/model.d.ts +20 -0
- package/dist/runtime/model.js +241 -0
- package/dist/runtime/observability.d.ts +22 -0
- package/dist/runtime/observability.js +104 -0
- package/dist/runtime/policy.d.ts +8 -0
- package/dist/runtime/policy.js +31 -0
- package/dist/runtime/profile.d.ts +8 -0
- package/dist/runtime/profile.js +52 -0
- package/dist/runtime/projects.d.ts +40 -0
- package/dist/runtime/projects.js +115 -0
- package/dist/runtime/results.d.ts +7 -0
- package/dist/runtime/results.js +55 -0
- package/dist/runtime/review.d.ts +9 -0
- package/dist/runtime/review.js +54 -0
- package/dist/runtime/scheduler.d.ts +15 -0
- package/dist/runtime/scheduler.js +85 -0
- package/dist/runtime/skills.d.ts +2 -0
- package/dist/runtime/skills.js +83 -0
- package/dist/runtime/tokenizer.d.ts +8 -0
- package/dist/runtime/tokenizer.js +30 -0
- package/dist/store/database.d.ts +3 -0
- package/dist/store/database.js +49 -0
- package/dist/store/event-log.d.ts +19 -0
- package/dist/store/event-log.js +61 -0
- package/dist/store/schema.d.ts +1 -0
- package/dist/store/schema.js +347 -0
- package/dist/store/store.d.ts +344 -0
- package/dist/store/store.js +2554 -0
- package/package.json +73 -0
- package/proto/executor.proto +314 -0
|
@@ -0,0 +1,2554 @@
|
|
|
1
|
+
import { digest, id, json, now, parseJson } from '../core/codec.js';
|
|
2
|
+
import { fail } from '../core/domain.js';
|
|
3
|
+
import { policyPrompt } from '../runtime/policy.js';
|
|
4
|
+
export class Store {
|
|
5
|
+
#database;
|
|
6
|
+
#events;
|
|
7
|
+
#eventLog;
|
|
8
|
+
constructor(database, events, eventLog) {
|
|
9
|
+
this.#database = database;
|
|
10
|
+
this.#events = events;
|
|
11
|
+
this.#eventLog = eventLog;
|
|
12
|
+
}
|
|
13
|
+
async close() {
|
|
14
|
+
await this.#eventLog?.close();
|
|
15
|
+
this.#database.close();
|
|
16
|
+
}
|
|
17
|
+
instance() {
|
|
18
|
+
return doc(this.#one('SELECT * FROM instance WHERE id = 1'));
|
|
19
|
+
}
|
|
20
|
+
stateRevision() {
|
|
21
|
+
return Number(this.#one('SELECT state_revision FROM instance WHERE id = 1').state_revision);
|
|
22
|
+
}
|
|
23
|
+
ingressReplay(input) {
|
|
24
|
+
return this.#ingressReplay(input);
|
|
25
|
+
}
|
|
26
|
+
recover() {
|
|
27
|
+
const interrupted = [];
|
|
28
|
+
const transaction = this.#database.transaction(() => {
|
|
29
|
+
const timestamp = now();
|
|
30
|
+
const compactionRunIds = new Set(this.#all(`
|
|
31
|
+
SELECT DISTINCT r.id FROM model_attempts ma
|
|
32
|
+
JOIN steps s ON s.id = ma.step_id
|
|
33
|
+
JOIN runs r ON r.id = s.run_id
|
|
34
|
+
WHERE ma.kind = 'compaction' AND ma.state IN ('PREPARED', 'RUNNING')
|
|
35
|
+
`).map((row) => String(row.id)));
|
|
36
|
+
const interruptedAgentIds = new Set(this.#all(`SELECT DISTINCT agent_id FROM runs WHERE state IN ('RUNNING', 'WAITING')`).map((row) => String(row.agent_id)));
|
|
37
|
+
const attempts = this.#all("SELECT id FROM model_attempts WHERE state IN ('PREPARED', 'RUNNING')");
|
|
38
|
+
for (const row of attempts)
|
|
39
|
+
interrupted.push({ type: 'model_attempt', id: String(row.id) });
|
|
40
|
+
this.#database.prepare(`UPDATE model_attempts SET state = 'TERMINAL', outcome = 'interrupted', error_code = 'PROCESS_INTERRUPTED', updated_at = ? WHERE state IN ('PREPARED', 'RUNNING')`).run(timestamp);
|
|
41
|
+
const calls = this.#all(`SELECT id, state FROM calls WHERE state = 'RUNNING' OR (step_id IS NOT NULL AND state IN ('WAITING', 'QUEUED'))`);
|
|
42
|
+
for (const row of calls) {
|
|
43
|
+
const callId = String(row.id);
|
|
44
|
+
interrupted.push({ type: 'call', id: callId });
|
|
45
|
+
const running = row.state === 'RUNNING';
|
|
46
|
+
const outcome = running ? 'interrupted' : 'cancelled';
|
|
47
|
+
const errorCode = running ? 'PROCESS_INTERRUPTED' : 'RECOVERY_CANCELLED';
|
|
48
|
+
this.#database.prepare(`UPDATE calls SET state = 'TERMINAL', outcome = ?, outcome_unknown = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
49
|
+
.run(outcome, running ? 1 : 0, timestamp, callId);
|
|
50
|
+
this.#database.prepare(`INSERT OR IGNORE INTO results(id, call_id, outcome, content_json, error_code, error_message, created_at) VALUES (?, ?, ?, '{}', ?, 'The Agent workflow was interrupted by process shutdown.', ?)`)
|
|
51
|
+
.run(id('result'), callId, outcome, errorCode, timestamp);
|
|
52
|
+
}
|
|
53
|
+
const ledgerSteps = this.#all(`SELECT DISTINCT s.id, r.agent_id FROM steps s JOIN runs r ON r.id = s.run_id JOIN calls c ON c.step_id = s.id WHERE s.state IN ('RUNNING', 'WAITING')`);
|
|
54
|
+
for (const row of ledgerSteps) {
|
|
55
|
+
const stepId = String(row.id);
|
|
56
|
+
const agentId = String(row.agent_id);
|
|
57
|
+
const entries = this.#all(`SELECT c.id, c.capability_id, c.call_ordinal, c.outcome, c.outcome_unknown, r.content_json, r.error_code FROM calls c LEFT JOIN results r ON r.call_id = c.id WHERE c.step_id = ? ORDER BY c.call_ordinal, c.id`, stepId)
|
|
58
|
+
.map((entry) => ({
|
|
59
|
+
call_id: String(entry.id),
|
|
60
|
+
capability_id: String(entry.capability_id),
|
|
61
|
+
call_ordinal: Number(entry.call_ordinal),
|
|
62
|
+
outcome: typeof entry.outcome === 'string' ? entry.outcome : null,
|
|
63
|
+
outcome_unknown: Number(entry.outcome_unknown) === 1,
|
|
64
|
+
content: typeof entry.content_json === 'string' ? parseJson(entry.content_json) : null,
|
|
65
|
+
error_code: typeof entry.error_code === 'string' ? entry.error_code : null
|
|
66
|
+
}));
|
|
67
|
+
const content = json({ version: 'failed-attempt-side-effect-ledger-v1', step_id: stepId, calls: entries });
|
|
68
|
+
const ledgerId = id('ledger');
|
|
69
|
+
const ids = { messageId: id('message'), contextId: id('context'), turnGroupId: id('turn') };
|
|
70
|
+
this.#database.prepare('INSERT INTO side_effect_ledgers(id, step_id, content, created_at) VALUES (?, ?, ?, ?)').run(ledgerId, stepId, content, timestamp);
|
|
71
|
+
this.#insertContextMessage(agentId, 'system', content, ids, timestamp, 'ledger');
|
|
72
|
+
interrupted.push({ type: 'side_effect_ledger', id: ledgerId }, { type: 'context_item', id: ids.contextId });
|
|
73
|
+
}
|
|
74
|
+
const steps = this.#all("SELECT id FROM steps WHERE state IN ('RUNNING', 'WAITING')");
|
|
75
|
+
for (const row of steps) {
|
|
76
|
+
const stepId = String(row.id);
|
|
77
|
+
const runId = String(this.#require('steps', stepId).run_id);
|
|
78
|
+
const compactionFailed = compactionRunIds.has(runId);
|
|
79
|
+
interrupted.push({ type: 'step', id: stepId });
|
|
80
|
+
this.#database.prepare(`UPDATE steps SET state = 'TERMINAL', outcome = ?, error_code = ?, updated_at = ? WHERE id = ?`)
|
|
81
|
+
.run(compactionFailed ? 'failed' : 'interrupted', compactionFailed ? 'COMPACTION_FAILED' : 'PROCESS_INTERRUPTED', timestamp, stepId);
|
|
82
|
+
}
|
|
83
|
+
this.#database.prepare(`UPDATE context_turn_groups SET state = 'ABORTED', revision = revision + 1, completed_at = ? WHERE state = 'OPEN'`).run(timestamp);
|
|
84
|
+
const runs = this.#all("SELECT id FROM runs WHERE state IN ('RUNNING', 'WAITING')");
|
|
85
|
+
for (const row of runs) {
|
|
86
|
+
const runId = String(row.id);
|
|
87
|
+
const compactionFailed = compactionRunIds.has(runId);
|
|
88
|
+
interrupted.push({ type: 'run', id: runId });
|
|
89
|
+
this.#database.prepare(`UPDATE runs SET state = 'TERMINAL', outcome = ?, error_code = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
90
|
+
.run(compactionFailed ? 'failed' : 'interrupted', compactionFailed ? 'COMPACTION_FAILED' : 'PROCESS_INTERRUPTED', timestamp, runId);
|
|
91
|
+
}
|
|
92
|
+
for (const agentId of interruptedAgentIds) {
|
|
93
|
+
this.#database.prepare(`UPDATE agents SET state = 'IDLE', revision = revision + 1, updated_at = ? WHERE id = ?`).run(timestamp, agentId);
|
|
94
|
+
interrupted.push({ type: 'agent', id: agentId });
|
|
95
|
+
}
|
|
96
|
+
if (interrupted.length > 0)
|
|
97
|
+
this.#database.prepare('UPDATE instance SET state_revision = state_revision + 1 WHERE id = 1').run();
|
|
98
|
+
});
|
|
99
|
+
transaction();
|
|
100
|
+
return interrupted;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 02A `ProjectPageQuery.archive`: `active` hides archived Projects, `archived` shows only those,
|
|
104
|
+
* `all` shows both. The Route previously read a differently spelled parameter, so the documented
|
|
105
|
+
* one silently had no effect and archived Projects were unreachable through the API.
|
|
106
|
+
*/
|
|
107
|
+
listProjects(options, archive = 'active') {
|
|
108
|
+
const clause = archive === 'all' ? '1 = 1' : archive === 'archived' ? 'archived_at IS NOT NULL' : 'archived_at IS NULL';
|
|
109
|
+
return this.#page(`SELECT * FROM projects WHERE ${clause}`, [], options);
|
|
110
|
+
}
|
|
111
|
+
defaultProject() {
|
|
112
|
+
const row = this.#database.prepare(`SELECT * FROM projects WHERE archived_at IS NULL ORDER BY created_at, id LIMIT 1`).get();
|
|
113
|
+
return row === undefined ? null : doc(row);
|
|
114
|
+
}
|
|
115
|
+
getProject(projectId) {
|
|
116
|
+
return doc(this.#require('projects', projectId));
|
|
117
|
+
}
|
|
118
|
+
createProject(name, paths) {
|
|
119
|
+
const projectId = id('project');
|
|
120
|
+
const workspaceId = id('workspace');
|
|
121
|
+
const timestamp = now();
|
|
122
|
+
return this.#write(projectId, refs(['project', projectId], ['workspace', workspaceId]), () => {
|
|
123
|
+
this.#database.prepare(`INSERT INTO projects(id, name, archived_at, revision, created_at, updated_at) VALUES (?, ?, NULL, 0, ?, ?)`)
|
|
124
|
+
.run(projectId, name, timestamp, timestamp);
|
|
125
|
+
this.#database.prepare(`INSERT INTO workspaces(id, project_id, name, is_default, revision, created_at, updated_at) VALUES (?, ?, 'Default', 1, 0, ?, ?)`)
|
|
126
|
+
.run(workspaceId, projectId, timestamp, timestamp);
|
|
127
|
+
this.#replaceWorkspacePaths(workspaceId, paths);
|
|
128
|
+
return this.getProject(projectId);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
updateProject(projectId, expectedRevision, name) {
|
|
132
|
+
return this.#write(projectId, refs(['project', projectId]), () => {
|
|
133
|
+
this.#expect(this.#require('projects', projectId), expectedRevision);
|
|
134
|
+
this.#database.prepare('UPDATE projects SET name = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
135
|
+
.run(name, now(), projectId);
|
|
136
|
+
return this.getProject(projectId);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
archiveProject(projectId, expectedRevision, archived) {
|
|
140
|
+
return this.#write(projectId, refs(['project', projectId]), () => {
|
|
141
|
+
this.#expect(this.#require('projects', projectId), expectedRevision);
|
|
142
|
+
if (archived && this.#count(`SELECT COUNT(*) AS count FROM calls WHERE project_id = ? AND state IN ('WAITING', 'QUEUED', 'RUNNING')`, projectId) > 0) {
|
|
143
|
+
fail('PROJECT_BUSY', 409, 'The Project has active Calls.');
|
|
144
|
+
}
|
|
145
|
+
this.#database.prepare('UPDATE projects SET archived_at = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
146
|
+
.run(archived ? now() : null, now(), projectId);
|
|
147
|
+
return this.getProject(projectId);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
listWorkspaces(projectId) {
|
|
151
|
+
this.#require('projects', projectId);
|
|
152
|
+
return this.#all('SELECT * FROM workspaces WHERE project_id = ? ORDER BY is_default DESC, name, id', projectId).map((row) => this.#workspace(row));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The ordered folder set a Call may reach: every Workspace in the Project, with the Call's own
|
|
156
|
+
* Workspace first.
|
|
157
|
+
*
|
|
158
|
+
* The bound Workspace leads because the first path is the primary one that relative paths resolve
|
|
159
|
+
* against, so an Agent's own folder stays the default target while the rest of the Project becomes
|
|
160
|
+
* readable. Paths are deduplicated: the same folder may appear in more than one Workspace, and a
|
|
161
|
+
* repeated root would make the Executor walk it twice.
|
|
162
|
+
*/
|
|
163
|
+
projectWorkspacePaths(projectId, workspaceId) {
|
|
164
|
+
const bound = this.getWorkspace(projectId, workspaceId);
|
|
165
|
+
const ordered = [bound, ...this.listWorkspaces(projectId).filter((workspace) => workspace.id !== bound.id)];
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
const paths = [];
|
|
168
|
+
for (const workspace of ordered) {
|
|
169
|
+
const value = workspace.paths;
|
|
170
|
+
if (!Array.isArray(value))
|
|
171
|
+
continue;
|
|
172
|
+
for (const entry of value) {
|
|
173
|
+
if (typeof entry !== 'string' || seen.has(entry))
|
|
174
|
+
continue;
|
|
175
|
+
seen.add(entry);
|
|
176
|
+
paths.push(entry);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return paths;
|
|
180
|
+
}
|
|
181
|
+
getWorkspace(projectId, workspaceId) {
|
|
182
|
+
const row = this.#require('workspaces', workspaceId);
|
|
183
|
+
owned(row, 'project_id', projectId);
|
|
184
|
+
return this.#workspace(row);
|
|
185
|
+
}
|
|
186
|
+
createWorkspace(projectId, name, paths, makeDefault) {
|
|
187
|
+
const workspaceId = id('workspace');
|
|
188
|
+
const timestamp = now();
|
|
189
|
+
return this.#write(projectId, refs(['workspace', workspaceId]), () => {
|
|
190
|
+
this.#activeProject(projectId);
|
|
191
|
+
if (makeDefault)
|
|
192
|
+
this.#database.prepare('UPDATE workspaces SET is_default = 0 WHERE project_id = ?').run(projectId);
|
|
193
|
+
// `UNIQUE(project_id, name)` is a rejected input, not a storage failure. Left to escape it
|
|
194
|
+
// surfaced as INTERNAL_ERROR 500 marked retryable, which told the caller to repeat a request
|
|
195
|
+
// that can never succeed and blamed the Server for the client's name.
|
|
196
|
+
uniqueName(name, () => {
|
|
197
|
+
this.#database.prepare(`INSERT INTO workspaces(id, project_id, name, is_default, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?)`)
|
|
198
|
+
.run(workspaceId, projectId, name, makeDefault ? 1 : 0, timestamp, timestamp);
|
|
199
|
+
});
|
|
200
|
+
this.#replaceWorkspacePaths(workspaceId, paths);
|
|
201
|
+
return this.getWorkspace(projectId, workspaceId);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
updateWorkspace(projectId, workspaceId, expectedRevision, name, paths, makeDefault) {
|
|
205
|
+
return this.#write(projectId, refs(['workspace', workspaceId]), () => {
|
|
206
|
+
const current = this.#require('workspaces', workspaceId);
|
|
207
|
+
owned(current, 'project_id', projectId);
|
|
208
|
+
this.#expect(current, expectedRevision);
|
|
209
|
+
if (!makeDefault && Number(current.is_default) === 1)
|
|
210
|
+
fail('DEFAULT_WORKSPACE_REQUIRED', 409, 'Select another default Workspace first.');
|
|
211
|
+
if (makeDefault)
|
|
212
|
+
this.#database.prepare('UPDATE workspaces SET is_default = 0 WHERE project_id = ?').run(projectId);
|
|
213
|
+
uniqueName(name, () => {
|
|
214
|
+
this.#database.prepare(`UPDATE workspaces SET name = ?, is_default = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
215
|
+
.run(name, makeDefault ? 1 : 0, now(), workspaceId);
|
|
216
|
+
});
|
|
217
|
+
this.#replaceWorkspacePaths(workspaceId, paths);
|
|
218
|
+
return this.getWorkspace(projectId, workspaceId);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
deleteWorkspace(projectId, workspaceId, expectedRevision) {
|
|
222
|
+
this.#write(projectId, refs(['workspace', workspaceId]), () => {
|
|
223
|
+
const current = this.#require('workspaces', workspaceId);
|
|
224
|
+
owned(current, 'project_id', projectId);
|
|
225
|
+
this.#expect(current, expectedRevision);
|
|
226
|
+
if (Number(current.is_default) === 1)
|
|
227
|
+
fail('DEFAULT_WORKSPACE_REQUIRED', 409, 'The default Workspace cannot be deleted.');
|
|
228
|
+
// Agents and Calls reference a Workspace with ON DELETE RESTRICT, so history holds it in
|
|
229
|
+
// place. 07 documents that as `WORKSPACE_NOT_DELETABLE`; left to escape, the foreign key
|
|
230
|
+
// surfaced as INTERNAL_ERROR 500 and told the caller nothing about what still refers to it.
|
|
231
|
+
const agents = this.#count('SELECT COUNT(*) AS count FROM agents WHERE workspace_id = ?', workspaceId);
|
|
232
|
+
const calls = this.#count('SELECT COUNT(*) AS count FROM calls WHERE workspace_id = ?', workspaceId);
|
|
233
|
+
if (agents > 0 || calls > 0) {
|
|
234
|
+
fail('WORKSPACE_NOT_DELETABLE', 409, agents > 0
|
|
235
|
+
? `The Workspace is still bound to ${agents} Agent${agents === 1 ? '' : 's'}.`
|
|
236
|
+
: `The Workspace is referenced by ${calls} Call${calls === 1 ? '' : 's'} in history.`);
|
|
237
|
+
}
|
|
238
|
+
this.#database.prepare('DELETE FROM workspaces WHERE id = ?').run(workspaceId);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
listTasks(projectId, options, archived = false) {
|
|
242
|
+
this.#require('projects', projectId);
|
|
243
|
+
const page = this.#page(`SELECT * FROM tasks WHERE project_id = ? AND (? = 1 OR archived_at IS NULL)`, [projectId, archived ? 1 : 0], options);
|
|
244
|
+
const workspaces = this.#taskWorkspaceIds(page.items.map((task) => stringField(task.id)));
|
|
245
|
+
return {
|
|
246
|
+
...page,
|
|
247
|
+
items: page.items.map((task) => ({
|
|
248
|
+
...task,
|
|
249
|
+
workspace_ids: workspaces.get(stringField(task.id)) ?? [],
|
|
250
|
+
// Documented as nullable; retention has no per-Task column, so it is always absent today.
|
|
251
|
+
purge_after: task.purge_after ?? null
|
|
252
|
+
}))
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
listAllTasks(projectId, options, archived = false) {
|
|
256
|
+
return this.listTasks(projectId, options, archived);
|
|
257
|
+
}
|
|
258
|
+
getTask(projectId, taskId) {
|
|
259
|
+
const row = this.#require('tasks', taskId);
|
|
260
|
+
owned(row, 'project_id', projectId);
|
|
261
|
+
return {
|
|
262
|
+
...doc(row),
|
|
263
|
+
workspace_ids: this.#taskWorkspaceIds([taskId]).get(taskId) ?? [],
|
|
264
|
+
purge_after: doc(row).purge_after ?? null
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* A Project's Task/Session totals. `Page` carries no total, so without this a client had to walk
|
|
269
|
+
* every page to show a count.
|
|
270
|
+
*/
|
|
271
|
+
projectDetail(projectId) {
|
|
272
|
+
const project = this.getProject(projectId);
|
|
273
|
+
return {
|
|
274
|
+
project,
|
|
275
|
+
task_count: this.#count('SELECT COUNT(*) AS count FROM tasks WHERE project_id = ?', projectId),
|
|
276
|
+
active_task_count: this.#count('SELECT COUNT(*) AS count FROM tasks WHERE project_id = ? AND archived_at IS NULL', projectId),
|
|
277
|
+
session_count: this.#count('SELECT COUNT(*) AS count FROM sessions s JOIN tasks t ON t.id = s.task_id WHERE t.project_id = ?', projectId)
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 02A `TaskDetail`. The Route returned a bare `TaskSummary`, so a client reading `task.title`
|
|
282
|
+
* dereferenced `undefined` and the view crashed. Counts are derived, not stored.
|
|
283
|
+
*/
|
|
284
|
+
taskDetail(projectId, taskId) {
|
|
285
|
+
const task = this.getTask(projectId, taskId);
|
|
286
|
+
return {
|
|
287
|
+
task,
|
|
288
|
+
root_agent_count: this.#count('SELECT COUNT(*) AS count FROM agents WHERE task_id = ? AND parent_agent_id IS NULL', taskId),
|
|
289
|
+
total_agent_count: this.#count('SELECT COUNT(*) AS count FROM agents WHERE task_id = ?', taskId),
|
|
290
|
+
active_run_count: this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id
|
|
291
|
+
WHERE a.task_id = ? AND r.state <> 'TERMINAL'`, taskId)
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
createTask(projectId, title) {
|
|
295
|
+
const taskId = id('task');
|
|
296
|
+
const timestamp = now();
|
|
297
|
+
return this.#write(projectId, refs(['task', taskId]), () => {
|
|
298
|
+
this.#activeProject(projectId);
|
|
299
|
+
this.#database.prepare(`INSERT INTO tasks(id, project_id, title, archived_at, revision, created_at, updated_at) VALUES (?, ?, ?, NULL, 0, ?, ?)`)
|
|
300
|
+
.run(taskId, projectId, title, timestamp, timestamp);
|
|
301
|
+
return this.getTask(projectId, taskId);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
updateTask(projectId, taskId, expectedRevision, title) {
|
|
305
|
+
return this.#write(projectId, refs(['task', taskId]), () => {
|
|
306
|
+
const current = this.#require('tasks', taskId);
|
|
307
|
+
owned(current, 'project_id', projectId);
|
|
308
|
+
this.#expect(current, expectedRevision);
|
|
309
|
+
this.#database.prepare('UPDATE tasks SET title = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
310
|
+
.run(title, now(), taskId);
|
|
311
|
+
return this.getTask(projectId, taskId);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
archiveTask(projectId, taskId, expectedRevision, archived) {
|
|
315
|
+
return this.#write(projectId, refs(['task', taskId]), () => {
|
|
316
|
+
const current = this.#require('tasks', taskId);
|
|
317
|
+
owned(current, 'project_id', projectId);
|
|
318
|
+
this.#expect(current, expectedRevision);
|
|
319
|
+
if (archived && this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id WHERE a.task_id = ? AND r.state != 'TERMINAL'`, taskId) > 0) {
|
|
320
|
+
fail('TASK_BUSY', 409, 'The Task has active Runs.');
|
|
321
|
+
}
|
|
322
|
+
this.#database.prepare('UPDATE tasks SET archived_at = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
323
|
+
.run(archived ? now() : null, now(), taskId);
|
|
324
|
+
return this.getTask(projectId, taskId);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
deleteTask(projectId, taskId, expectedRevision) {
|
|
328
|
+
return this.#write(projectId, refs(['task', taskId]), () => {
|
|
329
|
+
const current = this.#require('tasks', taskId);
|
|
330
|
+
owned(current, 'project_id', projectId);
|
|
331
|
+
this.#expect(current, expectedRevision);
|
|
332
|
+
if (current.archived_at === null)
|
|
333
|
+
fail('TASK_NOT_ARCHIVED', 409, 'Archive the Task before deleting it.');
|
|
334
|
+
return this.#purgeTaskRows([taskId]);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
listSessions(projectId, taskId, options, archived = false) {
|
|
338
|
+
this.getTask(projectId, taskId);
|
|
339
|
+
const page = this.#page(`SELECT * FROM sessions WHERE task_id = ? AND (? = 1 OR archived_at IS NULL)`, [taskId, archived ? 1 : 0], options);
|
|
340
|
+
return { ...page, items: page.items.map((session) => this.#sessionSummary(session, projectId)) };
|
|
341
|
+
}
|
|
342
|
+
getSession(projectId, sessionId) {
|
|
343
|
+
const row = this.#require('sessions', sessionId);
|
|
344
|
+
this.#ownedTask(String(row.task_id), projectId);
|
|
345
|
+
return this.#sessionSummary(doc(row), projectId);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* 02A `SessionSummary`. The stored row holds only title, timestamps and revision; `project_id`,
|
|
349
|
+
* `root_agent_id`, `active_run_count` and `status` are derived. They were absent from the wire
|
|
350
|
+
* entirely, so the rail showed every Session as "Ready" and its live indicator never lit.
|
|
351
|
+
*/
|
|
352
|
+
#sessionSummary(session, projectId) {
|
|
353
|
+
const sessionId = stringField(session.id);
|
|
354
|
+
const rootAgent = this.#all('SELECT id FROM agents WHERE session_id = ? AND parent_agent_id IS NULL ORDER BY created_at, id LIMIT 1', sessionId)[0];
|
|
355
|
+
const running = this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id
|
|
356
|
+
WHERE a.session_id = ? AND r.state = 'RUNNING'`, sessionId);
|
|
357
|
+
const waiting = this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id
|
|
358
|
+
WHERE a.session_id = ? AND r.state = 'WAITING'`, sessionId);
|
|
359
|
+
const pending = this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id
|
|
360
|
+
WHERE a.session_id = ? AND r.state <> 'TERMINAL'`, sessionId);
|
|
361
|
+
// The outcome of the most recent Run, not a count of every Run that ever failed. Counting made the
|
|
362
|
+
// status permanent: one failed Run pinned a Session to `failed` for the rest of its life, however
|
|
363
|
+
// many successful Runs followed, because a count of past failures never goes down. A status badge
|
|
364
|
+
// answers "what happened last", so that is what this reads.
|
|
365
|
+
//
|
|
366
|
+
// A cancelled last Run reports `ready`: the user stopped it deliberately, which is neither a
|
|
367
|
+
// failure nor a completion, and the Session is ready for the next message. `interrupted` does
|
|
368
|
+
// report `failed` -- nothing chose that, the Run was cut off.
|
|
369
|
+
const latest = this.#all(`SELECT r.outcome FROM runs r JOIN agents a ON a.id = r.agent_id
|
|
370
|
+
WHERE a.session_id = ? AND r.state = 'TERMINAL'
|
|
371
|
+
ORDER BY r.created_at DESC, r.id DESC LIMIT 1`, sessionId)[0];
|
|
372
|
+
const outcome = latest === undefined ? null : String(latest.outcome);
|
|
373
|
+
return {
|
|
374
|
+
...session,
|
|
375
|
+
project_id: projectId,
|
|
376
|
+
root_agent_id: rootAgent === undefined ? null : String(rootAgent.id),
|
|
377
|
+
active_run_count: pending,
|
|
378
|
+
status: session.archived_at !== null
|
|
379
|
+
? 'archived'
|
|
380
|
+
: running > 0
|
|
381
|
+
? 'running'
|
|
382
|
+
: waiting > 0
|
|
383
|
+
? 'waiting'
|
|
384
|
+
: outcome === 'failed' || outcome === 'interrupted'
|
|
385
|
+
? 'failed'
|
|
386
|
+
: outcome === 'success'
|
|
387
|
+
? 'completed'
|
|
388
|
+
: 'ready'
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* 02A `SessionDetail`. The Route returned a bare `SessionSummary`, so `root_agent` was absent and
|
|
393
|
+
* a client reading the active Agent's Workspace dereferenced `undefined`.
|
|
394
|
+
*/
|
|
395
|
+
sessionDetail(projectId, sessionId) {
|
|
396
|
+
const session = this.getSession(projectId, sessionId);
|
|
397
|
+
const rootAgentId = session.root_agent_id;
|
|
398
|
+
const rootAgent = typeof rootAgentId === 'string' ? this.getAgent(projectId, rootAgentId) : null;
|
|
399
|
+
return {
|
|
400
|
+
session,
|
|
401
|
+
root_agent: rootAgent,
|
|
402
|
+
runtime: rootAgent === null ? null : this.#agentRuntime(rootAgent),
|
|
403
|
+
active_run: rootAgent === null ? null : this.#activeRun(stringField(rootAgent.id)),
|
|
404
|
+
state_revision: String(this.stateRevision())
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/** 02A `AgentDetail`. */
|
|
408
|
+
agentDetail(projectId, agentId) {
|
|
409
|
+
const agent = this.getAgent(projectId, agentId);
|
|
410
|
+
return {
|
|
411
|
+
agent,
|
|
412
|
+
session: this.#agentRuntime(agent),
|
|
413
|
+
active_run: this.#activeRun(agentId)
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* 02A `AgentSessionRuntimeSummary`. Only `context_revision` is stored today; the Work Context and
|
|
418
|
+
* token-estimate fields have no column yet, so they report `null` rather than a fabricated value.
|
|
419
|
+
*/
|
|
420
|
+
#agentRuntime(agent) {
|
|
421
|
+
const agentId = stringField(agent.id);
|
|
422
|
+
const pending = this.#all('SELECT COUNT(*) AS count, MAX(ordinal) AS high FROM pending_messages WHERE agent_id = ?', agentId)[0];
|
|
423
|
+
return {
|
|
424
|
+
session_id: agent.session_id ?? null,
|
|
425
|
+
agent_session_id: agentId,
|
|
426
|
+
agent_id: agentId,
|
|
427
|
+
context_revision: agent.context_revision ?? 0,
|
|
428
|
+
context_high_water: this.#count('SELECT COUNT(*) AS count FROM context_items WHERE agent_id = ?', agentId),
|
|
429
|
+
pending_seq_high_water: Number(pending?.high ?? 0),
|
|
430
|
+
pending_revision: Number(pending?.count ?? 0),
|
|
431
|
+
work_context_revision: null,
|
|
432
|
+
work_context_digest: null,
|
|
433
|
+
request_context_frame_digest: null,
|
|
434
|
+
token_estimate: null
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** The newest run for an Agent that has not reached `TERMINAL`, as 02A `RunSummary`. */
|
|
438
|
+
#activeRun(agentId) {
|
|
439
|
+
const row = this.#all(`SELECT * FROM runs WHERE agent_id = ? AND state <> 'TERMINAL' ORDER BY created_at DESC, id DESC LIMIT 1`, agentId)[0];
|
|
440
|
+
return row === undefined ? null : this.#runSummary(doc(row));
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* 02A `RunSummary`. The stored row keeps `state` and `outcome` separate; `status` is the single
|
|
444
|
+
* documented projection of the two, and clients switch on it.
|
|
445
|
+
*/
|
|
446
|
+
#runSummary(run) {
|
|
447
|
+
const state = stringField(run.state);
|
|
448
|
+
const outcome = typeof run.outcome === 'string' ? run.outcome : null;
|
|
449
|
+
const terminal = {
|
|
450
|
+
success: 'COMPLETED', failed: 'FAILED', cancelled: 'CANCELLED', interrupted: 'INTERRUPTED'
|
|
451
|
+
};
|
|
452
|
+
return {
|
|
453
|
+
...run,
|
|
454
|
+
run_id: run.id ?? null,
|
|
455
|
+
session_id: run.agent_id ?? null,
|
|
456
|
+
agent_session_id: run.agent_id ?? null,
|
|
457
|
+
retry_of_run_id: run.retry_of_run_id ?? null,
|
|
458
|
+
initial_step_pending: run.initial_step_pending === 1,
|
|
459
|
+
tool_continuation_pending: false,
|
|
460
|
+
status: state === 'TERMINAL' && outcome !== null ? terminal[outcome] ?? 'FAILED' : state,
|
|
461
|
+
started_at: run.started_at ?? null,
|
|
462
|
+
ended_at: run.ended_at ?? null,
|
|
463
|
+
error_code: run.error_code ?? null
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
createSession(projectId, taskId, title, ingress) {
|
|
467
|
+
const replay = ingress === undefined ? null : this.#ingressReplay(ingress);
|
|
468
|
+
if (replay !== null)
|
|
469
|
+
return replay;
|
|
470
|
+
const sessionId = id('session');
|
|
471
|
+
const timestamp = now();
|
|
472
|
+
return this.#write(projectId, refs(['session', sessionId]), () => {
|
|
473
|
+
const task = this.#require('tasks', taskId);
|
|
474
|
+
owned(task, 'project_id', projectId);
|
|
475
|
+
if (task.archived_at !== null)
|
|
476
|
+
fail('TASK_ARCHIVED', 409, 'The Task is archived.');
|
|
477
|
+
this.#database.prepare(`INSERT INTO sessions(id, task_id, title, archived_at, revision, created_at, updated_at) VALUES (?, ?, ?, NULL, 0, ?, ?)`)
|
|
478
|
+
.run(sessionId, taskId, title, timestamp, timestamp);
|
|
479
|
+
const session = this.getSession(projectId, sessionId);
|
|
480
|
+
if (ingress !== undefined)
|
|
481
|
+
this.#recordIngress(ingress, session);
|
|
482
|
+
return session;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
createSessionWithAgent(projectId, input) {
|
|
486
|
+
const replay = this.#ingressReplay(input.ingress);
|
|
487
|
+
if (replay !== null)
|
|
488
|
+
return replay;
|
|
489
|
+
const sessionId = id('session');
|
|
490
|
+
const agentId = id('agent');
|
|
491
|
+
const promptIds = input.initialPrompt === undefined ? null : { messageId: id('message'), contextId: id('context'), turnGroupId: id('turn') };
|
|
492
|
+
const messageIds = input.initialMessage === undefined ? null : messageIdsForWrite();
|
|
493
|
+
const changes = [
|
|
494
|
+
['session', sessionId],
|
|
495
|
+
['agent', agentId],
|
|
496
|
+
...(promptIds === null ? [] : [
|
|
497
|
+
['message', promptIds.messageId],
|
|
498
|
+
['turn_group', promptIds.turnGroupId],
|
|
499
|
+
['context_item', promptIds.contextId]
|
|
500
|
+
]),
|
|
501
|
+
...(messageIds === null ? [] : messageRefs(messageIds))
|
|
502
|
+
];
|
|
503
|
+
return this.#write(projectId, refs(...changes), () => {
|
|
504
|
+
const task = this.#require('tasks', input.taskId);
|
|
505
|
+
owned(task, 'project_id', projectId);
|
|
506
|
+
if (task.archived_at !== null)
|
|
507
|
+
fail('TASK_ARCHIVED', 409, 'The Task is archived.');
|
|
508
|
+
const timestamp = now();
|
|
509
|
+
this.#database.prepare(`INSERT INTO sessions(id, task_id, title, archived_at, revision, created_at, updated_at) VALUES (?, ?, ?, NULL, 0, ?, ?)`)
|
|
510
|
+
.run(sessionId, input.taskId, input.title, timestamp, timestamp);
|
|
511
|
+
this.#insertAgent(projectId, agentId, {
|
|
512
|
+
taskId: input.taskId,
|
|
513
|
+
sessionId,
|
|
514
|
+
workspaceId: input.workspaceId,
|
|
515
|
+
name: input.agentName,
|
|
516
|
+
model: input.model,
|
|
517
|
+
modelReasoningEffort: input.modelReasoningEffort,
|
|
518
|
+
policyPreset: input.policyPreset,
|
|
519
|
+
capabilities: input.capabilities,
|
|
520
|
+
loadedCapabilities: input.loadedCapabilities
|
|
521
|
+
}, timestamp);
|
|
522
|
+
if (promptIds !== null && input.initialPrompt !== undefined) {
|
|
523
|
+
this.#insertContextMessage(agentId, 'system', input.initialPrompt, promptIds, timestamp);
|
|
524
|
+
}
|
|
525
|
+
const queued = messageIds === null || input.initialMessage === undefined
|
|
526
|
+
? null
|
|
527
|
+
: this.#insertQueuedMessage(projectId, agentId, input.initialMessage, undefined, messageIds, timestamp);
|
|
528
|
+
const response = {
|
|
529
|
+
session: this.getSession(projectId, sessionId),
|
|
530
|
+
agent: this.getAgent(projectId, agentId),
|
|
531
|
+
assignment: this.listCapabilities(projectId, agentId),
|
|
532
|
+
run: queued?.run ?? null
|
|
533
|
+
};
|
|
534
|
+
this.#recordIngress(input.ingress, response);
|
|
535
|
+
return response;
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
updateSession(projectId, sessionId, expectedRevision, title) {
|
|
539
|
+
return this.#write(projectId, refs(['session', sessionId]), () => {
|
|
540
|
+
const current = this.#require('sessions', sessionId);
|
|
541
|
+
this.#ownedTask(String(current.task_id), projectId);
|
|
542
|
+
this.#expect(current, expectedRevision);
|
|
543
|
+
this.#database.prepare('UPDATE sessions SET title = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
544
|
+
.run(title, now(), sessionId);
|
|
545
|
+
return this.getSession(projectId, sessionId);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
archiveSession(projectId, sessionId, expectedRevision, archived) {
|
|
549
|
+
return this.#write(projectId, refs(['session', sessionId]), () => {
|
|
550
|
+
const current = this.#require('sessions', sessionId);
|
|
551
|
+
this.#ownedTask(String(current.task_id), projectId);
|
|
552
|
+
this.#expect(current, expectedRevision);
|
|
553
|
+
if (archived && this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id WHERE a.session_id = ? AND r.state != 'TERMINAL'`, sessionId) > 0) {
|
|
554
|
+
fail('SESSION_BUSY', 409, 'The Session has active Runs.');
|
|
555
|
+
}
|
|
556
|
+
this.#database.prepare('UPDATE sessions SET archived_at = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
557
|
+
.run(archived ? now() : null, now(), sessionId);
|
|
558
|
+
return this.getSession(projectId, sessionId);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* 02A `SessionSnapshot`, bounded to the Session's live objects per 07 §163. It answered five fields
|
|
563
|
+
* of the thirteen and named the revision `revision` instead of `state_revision`, so the Trace tab
|
|
564
|
+
* read `snapshot.steps` and the Runs tab read `snapshot.agent_sessions` off `undefined` -- both threw
|
|
565
|
+
* during render, and with one boundary around the whole workbench that blanked the window.
|
|
566
|
+
*
|
|
567
|
+
* `messages` and `context_items` are still absent. 07 §163 bounds this to non-terminal run objects
|
|
568
|
+
* and names neither, and a Session's transcript is unbounded and already paged by its own Routes;
|
|
569
|
+
* sending it on every panel poll would be the opposite of a bounded read. Returning `[]` instead
|
|
570
|
+
* would be worse than omitting them -- it would assert there are none.
|
|
571
|
+
*/
|
|
572
|
+
sessionSnapshot(projectId, sessionId) {
|
|
573
|
+
const session = this.getSession(projectId, sessionId);
|
|
574
|
+
const agents = this.listAgents(projectId, 'session_id', sessionId);
|
|
575
|
+
const agentIds = agents.map((entry) => stringField(entry.id));
|
|
576
|
+
// The Snapshot feeds the Session side panel, which renders the same `RunSummary` and `CallSummary`
|
|
577
|
+
// the paged Routes return -- so it projects them the same way. Raw rows here meant that panel read
|
|
578
|
+
// `requested_capability` and `status` off records that never carried either.
|
|
579
|
+
//
|
|
580
|
+
// The window is the most recent Runs, not the non-terminal ones. 07 §163 requires this read to be
|
|
581
|
+
// bounded and reserves *non-terminal only* for the global Snapshot; bounding a Session's own
|
|
582
|
+
// Snapshot the same way made the Trace and Runs tabs empty for every Session that had finished,
|
|
583
|
+
// which is almost all of them -- a Session with four Runs reported none. A most-recent-N window is
|
|
584
|
+
// bounded and is the thing those tabs exist to show.
|
|
585
|
+
const runs = this.#recentRuns(agentIds).map((run) => this.#runSummary(run));
|
|
586
|
+
const runIds = runs.map((run) => stringField(run.id));
|
|
587
|
+
const steps = this.#in('steps', 'run_id', runIds, 'ordinal').map((step) => this.#stepRuntime(step));
|
|
588
|
+
const stepIds = steps.map((step) => stringField(step.step_id));
|
|
589
|
+
// Calls of the windowed Steps, plus any Call still waiting: a Call that needs the user must surface
|
|
590
|
+
// even when its Step has aged out of the window.
|
|
591
|
+
const calls = this.#callSummaries(dedupeById([
|
|
592
|
+
...this.#in('calls', 'step_id', stepIds, 'call_ordinal'),
|
|
593
|
+
...agentIds.flatMap((agentId) => this.#all('SELECT * FROM calls WHERE agent_id = ? AND state != \'TERMINAL\' ORDER BY updated_at DESC', agentId).map(doc))
|
|
594
|
+
]));
|
|
595
|
+
return {
|
|
596
|
+
state_revision: String(this.stateRevision()),
|
|
597
|
+
session,
|
|
598
|
+
agents,
|
|
599
|
+
agent_sessions: agents.map((agent) => this.#agentRuntime(agent)),
|
|
600
|
+
pending_messages: this.#in('pending_messages', 'agent_id', agentIds, 'ordinal'),
|
|
601
|
+
runs,
|
|
602
|
+
steps,
|
|
603
|
+
model_attempts: this.#in('model_attempts', 'step_id', stepIds, 'sequence'),
|
|
604
|
+
partitions: this.#in('call_partitions', 'step_id', stepIds, 'ordinal'),
|
|
605
|
+
calls,
|
|
606
|
+
attention: attentionItems(calls)
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* 02A `StepRuntimeSummary`. `status` is the documented projection of `state`/`outcome`, the same
|
|
611
|
+
* split `RunSummary.status` makes. `stream_closed` has no column and is derived: a Step is still
|
|
612
|
+
* streaming only while it is `RUNNING`. `reactive_compaction_attempted`, `assistant_message_id` and
|
|
613
|
+
* `ended_at` have no storage at all, so they report `false`/`null` rather than a fabricated value,
|
|
614
|
+
* and `revision` is null because `steps` carries no revision column to report.
|
|
615
|
+
*/
|
|
616
|
+
#stepRuntime(step) {
|
|
617
|
+
const state = step.state ?? null;
|
|
618
|
+
const outcome = typeof step.outcome === 'string' ? step.outcome : null;
|
|
619
|
+
const terminal = {
|
|
620
|
+
success: 'COMPLETED', failed: 'FAILED', cancelled: 'CANCELLED', interrupted: 'INTERRUPTED'
|
|
621
|
+
};
|
|
622
|
+
return {
|
|
623
|
+
...step,
|
|
624
|
+
step_id: step.id ?? null,
|
|
625
|
+
run_id: step.run_id ?? null,
|
|
626
|
+
step_ordinal: step.ordinal ?? 0,
|
|
627
|
+
state,
|
|
628
|
+
outcome,
|
|
629
|
+
status: state === 'TERMINAL'
|
|
630
|
+
? terminal[outcome ?? ''] ?? 'FAILED'
|
|
631
|
+
: state === 'WAITING' ? 'WAITING_CALLS' : 'MODEL_STREAMING',
|
|
632
|
+
stream_closed: state !== 'RUNNING',
|
|
633
|
+
reactive_compaction_attempted: false,
|
|
634
|
+
assistant_message_id: null,
|
|
635
|
+
ended_at: null,
|
|
636
|
+
error_code: step.error_code ?? null,
|
|
637
|
+
revision: null
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* The Session's most recent Runs, oldest first so the panel can number Steps in order. Bounded by
|
|
642
|
+
* `SNAPSHOT_RUN_LIMIT`: a Session can accumulate Runs without limit, and this is read on every panel
|
|
643
|
+
* poll, so the whole history cannot be the answer. Older Runs remain on the paged Route.
|
|
644
|
+
*/
|
|
645
|
+
#recentRuns(agentIds) {
|
|
646
|
+
if (agentIds.length === 0)
|
|
647
|
+
return [];
|
|
648
|
+
const placeholders = agentIds.map(() => '?').join(', ');
|
|
649
|
+
return this.#all(`SELECT * FROM runs WHERE agent_id IN (${placeholders}) ORDER BY created_at DESC, id DESC LIMIT ?`, ...agentIds, SNAPSHOT_RUN_LIMIT).map(doc).reverse();
|
|
650
|
+
}
|
|
651
|
+
/** Rows of one table whose `column` is in `keys`, ordered by `order` then `id`. */
|
|
652
|
+
#in(table, column, keys, order) {
|
|
653
|
+
if (keys.length === 0)
|
|
654
|
+
return [];
|
|
655
|
+
return this.#all(`SELECT * FROM ${table} WHERE ${column} IN (${keys.map(() => '?').join(', ')}) ORDER BY ${order}, id`, ...keys).map(doc);
|
|
656
|
+
}
|
|
657
|
+
sessionTimeline(projectId, sessionId, options) {
|
|
658
|
+
this.getSession(projectId, sessionId);
|
|
659
|
+
return this.#page(`
|
|
660
|
+
SELECT 'message' AS entity_type, m.id, m.created_at, m.role AS state, m.content
|
|
661
|
+
FROM messages m JOIN agents a ON a.id = m.agent_id WHERE a.session_id = ?
|
|
662
|
+
UNION ALL
|
|
663
|
+
SELECT 'run' AS entity_type, r.id, r.created_at, r.state, NULL AS content
|
|
664
|
+
FROM runs r JOIN agents a ON a.id = r.agent_id WHERE a.session_id = ?
|
|
665
|
+
`, [sessionId, sessionId], options, 'created_at DESC, id DESC');
|
|
666
|
+
}
|
|
667
|
+
listAgents(projectId, field, ownerId) {
|
|
668
|
+
if (field === 'task_id')
|
|
669
|
+
this.getTask(projectId, ownerId);
|
|
670
|
+
else
|
|
671
|
+
this.getSession(projectId, ownerId);
|
|
672
|
+
return this.#all(`SELECT * FROM agents WHERE ${field} = ? ORDER BY created_at, id`, ownerId).map((row) => this.#agent(row));
|
|
673
|
+
}
|
|
674
|
+
getAgent(projectId, agentId) {
|
|
675
|
+
const row = this.#require('agents', agentId);
|
|
676
|
+
this.#ownedTask(String(row.task_id), projectId);
|
|
677
|
+
return this.#agent(row);
|
|
678
|
+
}
|
|
679
|
+
createAgent(projectId, input) {
|
|
680
|
+
const agentId = id('agent');
|
|
681
|
+
const timestamp = now();
|
|
682
|
+
return this.#write(projectId, refs(['agent', agentId]), () => {
|
|
683
|
+
return this.#insertAgent(projectId, agentId, input, timestamp);
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
createSubagent(projectId, parentAgentId, input) {
|
|
687
|
+
const replay = input.ingress === undefined ? null : this.#ingressReplay(input.ingress);
|
|
688
|
+
if (replay !== null)
|
|
689
|
+
return replay;
|
|
690
|
+
if (input.forkContext === 'none' && input.initialPrompt === undefined && input.initialMessage === undefined) {
|
|
691
|
+
fail('SUBAGENT_INPUT_REQUIRED', 422, 'A Subagent without forked Context requires initial input.');
|
|
692
|
+
}
|
|
693
|
+
if (input.forkContext === 'compact' && input.compactSummary === undefined)
|
|
694
|
+
fail('COMPACTION_FAILED', 502, 'Compact Subagent creation requires a summary.');
|
|
695
|
+
const parent = this.getAgent(projectId, parentAgentId);
|
|
696
|
+
const agentId = id('agent');
|
|
697
|
+
const queuedIds = input.initialMessage === undefined ? null : messageIdsForWrite();
|
|
698
|
+
const initialRunId = queuedIds === null ? id('run') : null;
|
|
699
|
+
const changes = [
|
|
700
|
+
['agent', agentId],
|
|
701
|
+
...(initialRunId === null ? [] : [['run', initialRunId]]),
|
|
702
|
+
...(queuedIds === null ? [] : messageRefs(queuedIds))
|
|
703
|
+
];
|
|
704
|
+
return this.#write(projectId, refs(...changes), () => {
|
|
705
|
+
const timestamp = now();
|
|
706
|
+
this.#insertAgent(projectId, agentId, {
|
|
707
|
+
taskId: stringField(parent.task_id),
|
|
708
|
+
sessionId: stringField(parent.session_id),
|
|
709
|
+
workspaceId: stringField(parent.workspace_id),
|
|
710
|
+
parentAgentId,
|
|
711
|
+
name: input.name,
|
|
712
|
+
model: input.model,
|
|
713
|
+
modelReasoningEffort: input.modelReasoningEffort,
|
|
714
|
+
policyPreset: input.policyPreset,
|
|
715
|
+
capabilities: input.capabilities,
|
|
716
|
+
loadedCapabilities: input.loadedCapabilities
|
|
717
|
+
}, timestamp);
|
|
718
|
+
if (input.forkContext === 'full') {
|
|
719
|
+
const boundary = this.#database.prepare(`SELECT MAX(ordinal) AS ordinal FROM context_items WHERE agent_id = ? AND turn_group_id IN (SELECT id FROM context_turn_groups WHERE agent_id = ? AND state = 'COMPLETE')`).get(parentAgentId, parentAgentId);
|
|
720
|
+
if (boundary.ordinal !== null)
|
|
721
|
+
this.#copyContextRange(parentAgentId, agentId, Number(boundary.ordinal), timestamp);
|
|
722
|
+
}
|
|
723
|
+
else if (input.forkContext === 'compact' && input.compactSummary !== undefined) {
|
|
724
|
+
this.#insertStandaloneContextMessage(agentId, 'system', input.compactSummary, 'compaction', timestamp);
|
|
725
|
+
}
|
|
726
|
+
if (input.initialPrompt !== undefined)
|
|
727
|
+
this.#insertStandaloneContextMessage(agentId, 'system', input.initialPrompt, 'message', timestamp);
|
|
728
|
+
const queued = queuedIds === null || input.initialMessage === undefined
|
|
729
|
+
? null
|
|
730
|
+
: this.#insertQueuedMessage(projectId, agentId, input.initialMessage, parentAgentId, queuedIds, timestamp);
|
|
731
|
+
let run = queued?.run;
|
|
732
|
+
if (run === undefined) {
|
|
733
|
+
this.#database.prepare(`INSERT INTO runs(id, agent_id, goal_message_ids_json, initial_step_pending, state, outcome, error_code, revision, created_at, updated_at) VALUES (?, ?, '[]', 1, 'READY', NULL, NULL, 0, ?, ?)`)
|
|
734
|
+
.run(initialRunId, agentId, timestamp, timestamp);
|
|
735
|
+
run = doc(this.#require('runs', String(initialRunId)));
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
this.#database.prepare('UPDATE runs SET initial_step_pending = 1, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
739
|
+
.run(timestamp, run.id);
|
|
740
|
+
run = doc(this.#require('runs', stringField(run.id)));
|
|
741
|
+
}
|
|
742
|
+
const response = { agent: this.getAgent(projectId, agentId), message: queued?.message ?? null, pending: queued?.pending ?? null, run };
|
|
743
|
+
if (input.ingress !== undefined)
|
|
744
|
+
this.#recordIngress(input.ingress, response);
|
|
745
|
+
return response;
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
updateAgent(projectId, agentId, expectedRevision, input) {
|
|
749
|
+
return this.#write(projectId, refs(['agent', agentId]), () => {
|
|
750
|
+
const current = this.#require('agents', agentId);
|
|
751
|
+
this.#ownedTask(String(current.task_id), projectId);
|
|
752
|
+
this.#expect(current, expectedRevision);
|
|
753
|
+
if (String(current.state) !== 'IDLE')
|
|
754
|
+
fail('AGENT_BUSY', 409, 'The Agent must be idle before its configuration changes.');
|
|
755
|
+
this.#database.prepare('UPDATE agents SET name = ?, model = ?, model_reasoning_effort = ?, policy_preset = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
756
|
+
.run(input.name, input.model, input.modelReasoningEffort, input.policyPreset, now(), agentId);
|
|
757
|
+
return this.getAgent(projectId, agentId);
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
setAgentState(projectId, agentId, state) {
|
|
761
|
+
return this.#write(projectId, refs(['agent', agentId]), () => {
|
|
762
|
+
this.getAgent(projectId, agentId);
|
|
763
|
+
this.#database.prepare('UPDATE agents SET state = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
764
|
+
.run(state, now(), agentId);
|
|
765
|
+
return this.getAgent(projectId, agentId);
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
getMessage(projectId, messageId) {
|
|
769
|
+
const row = this.#require('messages', messageId);
|
|
770
|
+
this.#ownedAgent(String(row.agent_id), projectId);
|
|
771
|
+
return this.#message(doc(row));
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* 02A `MessageDocument`. The column is `content` but the documented body field is `body_text`,
|
|
775
|
+
* which is what a client renders, so message bodies arrived under a name nobody reads. `origin`
|
|
776
|
+
* distinguishes a model turn from a user turn and from one Agent writing to another; it is
|
|
777
|
+
* derived from the role and `source_agent_id` rather than stored.
|
|
778
|
+
*/
|
|
779
|
+
#message(message) {
|
|
780
|
+
const role = stringField(message.role);
|
|
781
|
+
const fromAgent = typeof message.source_agent_id === 'string';
|
|
782
|
+
return {
|
|
783
|
+
...message,
|
|
784
|
+
body_text: typeof message.content === 'string' ? message.content : '',
|
|
785
|
+
origin: fromAgent ? 'agent' : role === 'assistant' ? 'llm' : role === 'system' ? 'system' : role === 'tool' ? 'agent' : 'user',
|
|
786
|
+
origin_agent_id: typeof message.source_agent_id === 'string' ? message.source_agent_id : null,
|
|
787
|
+
// No column carries structured model output yet; `null` says so rather than reusing metadata.
|
|
788
|
+
structured: null
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
enqueueMessage(projectId, agentId, content, sourceAgentId, ingress) {
|
|
792
|
+
const replay = ingress === undefined ? null : this.#ingressReplay(ingress);
|
|
793
|
+
if (replay !== null)
|
|
794
|
+
return messageIngressResponse(replay);
|
|
795
|
+
const ids = messageIdsForWrite(this.#activeRunId(agentId));
|
|
796
|
+
const timestamp = now();
|
|
797
|
+
return this.#write(projectId, refs(...messageRefs(ids)), () => {
|
|
798
|
+
const response = this.#insertQueuedMessage(projectId, agentId, content, sourceAgentId, ids, timestamp);
|
|
799
|
+
if (ingress !== undefined)
|
|
800
|
+
this.#recordIngress(ingress, response);
|
|
801
|
+
return response;
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
enqueueSubagentMessage(projectId, parentAgentId, targetAgentId, content, ingress) {
|
|
805
|
+
const replay = this.#ingressReplay(ingress);
|
|
806
|
+
if (replay !== null)
|
|
807
|
+
return replay;
|
|
808
|
+
const ids = messageIdsForWrite(this.#activeRunId(targetAgentId));
|
|
809
|
+
return this.#write(projectId, refs(...messageRefs(ids)), () => {
|
|
810
|
+
const target = this.#require('agents', targetAgentId);
|
|
811
|
+
this.#ownedAgent(parentAgentId, projectId);
|
|
812
|
+
if (target.parent_agent_id !== parentAgentId)
|
|
813
|
+
fail('AGENT_NOT_FOUND', 404, 'The target is not a direct child Agent.');
|
|
814
|
+
const response = this.#insertQueuedMessage(projectId, targetAgentId, content, parentAgentId, ids, now());
|
|
815
|
+
this.#recordIngress(ingress, response);
|
|
816
|
+
return response;
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
broadcastSubagentMessage(projectId, parentAgentId, content, ingress) {
|
|
820
|
+
const replay = this.#ingressReplay(ingress);
|
|
821
|
+
if (replay !== null)
|
|
822
|
+
return replay;
|
|
823
|
+
const parent = this.getAgent(projectId, parentAgentId);
|
|
824
|
+
const targets = this.listAgents(projectId, 'task_id', stringField(parent.task_id)).filter((entry) => entry.parent_agent_id === parentAgentId);
|
|
825
|
+
const messages = targets.map((target) => {
|
|
826
|
+
const targetId = stringField(target.id);
|
|
827
|
+
return { targetId, ids: messageIdsForWrite(this.#activeRunId(targetId)) };
|
|
828
|
+
});
|
|
829
|
+
return this.#write(projectId, refs(...messages.flatMap((entry) => messageRefs(entry.ids))), () => {
|
|
830
|
+
const timestamp = now();
|
|
831
|
+
const response = {
|
|
832
|
+
messages: messages.map((entry) => this.#insertQueuedMessage(projectId, entry.targetId, content, parentAgentId, entry.ids, timestamp).message)
|
|
833
|
+
};
|
|
834
|
+
this.#recordIngress(ingress, response);
|
|
835
|
+
return response;
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
async waitForSubagent(projectId, sourceAgentId, targetAgentId, timeoutMs, signal) {
|
|
839
|
+
const source = this.getAgent(projectId, sourceAgentId);
|
|
840
|
+
const target = this.getAgent(projectId, targetAgentId);
|
|
841
|
+
if (source.task_id !== target.task_id || target.parent_agent_id === null)
|
|
842
|
+
fail('SUBAGENT_TARGET_INVALID', 400, 'The target must be a Subagent in the same Task.');
|
|
843
|
+
const deadline = Date.now() + Math.min(timeoutMs, 120000);
|
|
844
|
+
for (;;) {
|
|
845
|
+
signal.throwIfAborted();
|
|
846
|
+
const pending = this.#database.prepare(`SELECT * FROM pending_messages WHERE agent_id = ? AND source_agent_id = ? ORDER BY ordinal, id LIMIT 1`)
|
|
847
|
+
.get(sourceAgentId, targetAgentId);
|
|
848
|
+
if (pending !== undefined) {
|
|
849
|
+
return {
|
|
850
|
+
status: 'message',
|
|
851
|
+
target_agent_id: targetAgentId,
|
|
852
|
+
pending_message: doc(pending),
|
|
853
|
+
message: this.getMessage(projectId, String(pending.message_id))
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
const activeRunIds = this.#all(`SELECT id FROM runs WHERE agent_id = ? AND state IN ('READY', 'RUNNING', 'WAITING') ORDER BY created_at, id`, targetAgentId)
|
|
857
|
+
.map((row) => String(row.id));
|
|
858
|
+
if (activeRunIds.length === 0)
|
|
859
|
+
return { status: 'completed', target_agent_id: targetAgentId, active_run_ids: [] };
|
|
860
|
+
if (Date.now() >= deadline)
|
|
861
|
+
fail('CALL_TIMEOUT', 408, 'The Subagent wait timed out.', { target_agent_id: targetAgentId });
|
|
862
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, deadline - Date.now()))));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
listPending(projectId, agentId, options) {
|
|
866
|
+
this.getAgent(projectId, agentId);
|
|
867
|
+
return this.#page('SELECT * FROM pending_messages WHERE agent_id = ?', [agentId], options, 'ordinal ASC, id ASC');
|
|
868
|
+
}
|
|
869
|
+
getPending(projectId, pendingId) {
|
|
870
|
+
const row = this.#require('pending_messages', pendingId);
|
|
871
|
+
this.#ownedAgent(String(row.agent_id), projectId);
|
|
872
|
+
return doc(row);
|
|
873
|
+
}
|
|
874
|
+
updatePending(projectId, pendingId, expectedRevision, content) {
|
|
875
|
+
const current = this.#require('pending_messages', pendingId);
|
|
876
|
+
const agentId = String(current.agent_id);
|
|
877
|
+
return this.#write(projectId, refs(['pending_message', pendingId]), () => {
|
|
878
|
+
this.#ownedAgent(agentId, projectId);
|
|
879
|
+
this.#expect(current, expectedRevision);
|
|
880
|
+
this.#database.prepare('UPDATE pending_messages SET content = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
881
|
+
.run(content, now(), pendingId);
|
|
882
|
+
this.#database.prepare('UPDATE messages SET content = ? WHERE id = ?').run(content, current.message_id);
|
|
883
|
+
this.#database.prepare(`UPDATE context_items SET content = ?, revision = revision + 1, updated_at = ? WHERE agent_id = ? AND kind = 'message' AND source_id = ?`)
|
|
884
|
+
.run(content, now(), agentId, current.message_id);
|
|
885
|
+
this.#touchContext(agentId);
|
|
886
|
+
return this.getPending(projectId, pendingId);
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
deletePending(projectId, pendingId, expectedRevision) {
|
|
890
|
+
const current = this.#require('pending_messages', pendingId);
|
|
891
|
+
const agentId = String(current.agent_id);
|
|
892
|
+
this.#write(projectId, refs(['pending_message', pendingId]), () => {
|
|
893
|
+
this.#ownedAgent(agentId, projectId);
|
|
894
|
+
this.#expect(current, expectedRevision);
|
|
895
|
+
const timestamp = now();
|
|
896
|
+
this.#database.prepare(`UPDATE context_turn_groups SET state = 'ABORTED', revision = revision + 1, completed_at = ? WHERE id IN (SELECT turn_group_id FROM context_items WHERE agent_id = ? AND kind = 'message' AND source_id = ?) AND state = 'OPEN'`)
|
|
897
|
+
.run(timestamp, agentId, current.message_id);
|
|
898
|
+
this.#database.prepare('DELETE FROM pending_messages WHERE id = ?').run(pendingId);
|
|
899
|
+
if (this.#count('SELECT COUNT(*) AS count FROM pending_messages WHERE agent_id = ?', agentId) === 0) {
|
|
900
|
+
this.#database.prepare(`UPDATE runs SET state = 'TERMINAL', outcome = 'cancelled', error_code = 'PENDING_DELETED', revision = revision + 1, updated_at = ? WHERE agent_id = ? AND state = 'READY' AND initial_step_pending = 0`)
|
|
901
|
+
.run(timestamp, agentId);
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
reorderPending(projectId, agentId, ids) {
|
|
906
|
+
return this.#write(projectId, ids.map((entry) => ({ type: 'pending_message', id: entry })), () => {
|
|
907
|
+
this.getAgent(projectId, agentId);
|
|
908
|
+
const rows = this.#all('SELECT id FROM pending_messages WHERE agent_id = ? ORDER BY ordinal', agentId).map((row) => String(row.id));
|
|
909
|
+
if (rows.length !== ids.length || rows.some((entry) => !ids.includes(entry)))
|
|
910
|
+
fail('PENDING_SET_MISMATCH', 409, 'The pending Message set changed.');
|
|
911
|
+
const temporary = this.#database.prepare('UPDATE pending_messages SET ordinal = ?, revision = revision + 1, updated_at = ? WHERE id = ?');
|
|
912
|
+
ids.forEach((entry, ordinal) => temporary.run(ordinal + ids.length, now(), entry));
|
|
913
|
+
ids.forEach((entry, ordinal) => temporary.run(ordinal, now(), entry));
|
|
914
|
+
return this.#all('SELECT * FROM pending_messages WHERE agent_id = ? ORDER BY ordinal', agentId).map(doc);
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
listCapabilities(projectId, agentId) {
|
|
918
|
+
this.getAgent(projectId, agentId);
|
|
919
|
+
return this.#all(`
|
|
920
|
+
SELECT a.capability_id, a.policy, CASE WHEN l.capability_id IS NULL THEN 0 ELSE 1 END AS loaded
|
|
921
|
+
FROM capability_assignments a
|
|
922
|
+
LEFT JOIN capability_loads l ON l.agent_id = a.agent_id AND l.capability_id = a.capability_id
|
|
923
|
+
WHERE a.agent_id = ? ORDER BY a.capability_id
|
|
924
|
+
`, agentId).map(doc);
|
|
925
|
+
}
|
|
926
|
+
getCapabilityAssignment(projectId, agentId, capabilityId) {
|
|
927
|
+
this.getAgent(projectId, agentId);
|
|
928
|
+
const row = this.#database.prepare(`
|
|
929
|
+
SELECT a.capability_id, a.policy, CASE WHEN l.capability_id IS NULL THEN 0 ELSE 1 END AS loaded
|
|
930
|
+
FROM capability_assignments a
|
|
931
|
+
LEFT JOIN capability_loads l ON l.agent_id = a.agent_id AND l.capability_id = a.capability_id
|
|
932
|
+
WHERE a.agent_id = ? AND a.capability_id = ?
|
|
933
|
+
`).get(agentId, capabilityId);
|
|
934
|
+
if (row === undefined)
|
|
935
|
+
fail('CAPABILITY_NOT_ASSIGNED', 403, 'The Capability is not assigned to the Agent.');
|
|
936
|
+
return doc(row);
|
|
937
|
+
}
|
|
938
|
+
replaceCapabilities(projectId, agentId, capabilities, loadedCapabilities) {
|
|
939
|
+
return this.#write(projectId, refs(['agent', agentId]), () => {
|
|
940
|
+
const agent = this.#require('agents', agentId);
|
|
941
|
+
this.#ownedTask(String(agent.task_id), projectId);
|
|
942
|
+
if (String(agent.state) !== 'IDLE')
|
|
943
|
+
fail('AGENT_BUSY', 409, 'Capability assignment can only change while the Agent is idle.');
|
|
944
|
+
const parentCapabilities = agent.parent_agent_id === null
|
|
945
|
+
? null
|
|
946
|
+
: new Set(this.#all('SELECT capability_id FROM capability_assignments WHERE agent_id = ?', agent.parent_agent_id).map((row) => String(row.capability_id)));
|
|
947
|
+
this.#database.prepare('DELETE FROM capability_assignments WHERE agent_id = ?').run(agentId);
|
|
948
|
+
const assign = this.#database.prepare('INSERT INTO capability_assignments(agent_id, capability_id, policy, assigned_at) VALUES (?, ?, ?, ?)');
|
|
949
|
+
const load = this.#database.prepare('INSERT INTO capability_loads(agent_id, capability_id, loaded_at) VALUES (?, ?, ?)');
|
|
950
|
+
const timestamp = now();
|
|
951
|
+
for (const [capabilityId, policy] of Object.entries(capabilities)) {
|
|
952
|
+
if (parentCapabilities !== null && !parentCapabilities.has(capabilityId))
|
|
953
|
+
continue;
|
|
954
|
+
assign.run(agentId, capabilityId, policy, timestamp);
|
|
955
|
+
if (loadedCapabilities.includes(capabilityId))
|
|
956
|
+
load.run(agentId, capabilityId, timestamp);
|
|
957
|
+
}
|
|
958
|
+
return this.listCapabilities(projectId, agentId);
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
setCapabilityLoaded(projectId, agentId, capabilityId, loaded) {
|
|
962
|
+
return this.#write(projectId, refs(['agent', agentId]), () => {
|
|
963
|
+
const agent = this.#require('agents', agentId);
|
|
964
|
+
this.#ownedTask(String(agent.task_id), projectId);
|
|
965
|
+
if (String(agent.state) !== 'IDLE')
|
|
966
|
+
fail('AGENT_BUSY', 409, 'Capabilities can only load while the Agent is idle.');
|
|
967
|
+
const assignment = this.#database.prepare('SELECT 1 FROM capability_assignments WHERE agent_id = ? AND capability_id = ?').get(agentId, capabilityId);
|
|
968
|
+
if (assignment === undefined)
|
|
969
|
+
fail('CAPABILITY_NOT_ASSIGNED', 409, 'The Capability is not assigned to the Agent.');
|
|
970
|
+
if (loaded)
|
|
971
|
+
this.#database.prepare('INSERT OR IGNORE INTO capability_loads(agent_id, capability_id, loaded_at) VALUES (?, ?, ?)').run(agentId, capabilityId, now());
|
|
972
|
+
else
|
|
973
|
+
this.#database.prepare('DELETE FROM capability_loads WHERE agent_id = ? AND capability_id = ?').run(agentId, capabilityId);
|
|
974
|
+
return this.listCapabilities(projectId, agentId);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
loadCapabilities(projectId, agentId, capabilities) {
|
|
978
|
+
return this.#write(projectId, Object.keys(capabilities).map((capabilityId) => ({ type: 'capability_assignment', id: `${agentId}:${capabilityId}` })), () => {
|
|
979
|
+
this.getAgent(projectId, agentId);
|
|
980
|
+
const timestamp = now();
|
|
981
|
+
const load = this.#database.prepare('INSERT OR IGNORE INTO capability_loads(agent_id, capability_id, loaded_at) VALUES (?, ?, ?)');
|
|
982
|
+
for (const capabilityId of Object.keys(capabilities)) {
|
|
983
|
+
if (this.#database.prepare('SELECT 1 FROM capability_assignments WHERE agent_id = ? AND capability_id = ?').get(agentId, capabilityId) === undefined) {
|
|
984
|
+
fail('CAPABILITY_NOT_ASSIGNED', 403, 'The Capability is not assigned to the Agent.');
|
|
985
|
+
}
|
|
986
|
+
load.run(agentId, capabilityId, timestamp);
|
|
987
|
+
}
|
|
988
|
+
return this.listCapabilities(projectId, agentId);
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
listRuns(projectId, agentId, options) {
|
|
992
|
+
this.getAgent(projectId, agentId);
|
|
993
|
+
const page = this.#page('SELECT * FROM runs WHERE agent_id = ?', [agentId], options);
|
|
994
|
+
return { ...page, items: page.items.map((run) => this.#runSummary(run)) };
|
|
995
|
+
}
|
|
996
|
+
/** 02A `RunDetail`. */
|
|
997
|
+
runDetail(projectId, runId) {
|
|
998
|
+
const row = this.#require('runs', runId);
|
|
999
|
+
const agentId = String(row.agent_id);
|
|
1000
|
+
this.#ownedAgent(agentId, projectId);
|
|
1001
|
+
const agent = this.getAgent(projectId, agentId);
|
|
1002
|
+
const activeStep = this.#all(`SELECT id FROM steps WHERE run_id = ? AND state <> 'TERMINAL' ORDER BY created_at DESC, id DESC LIMIT 1`, runId)[0];
|
|
1003
|
+
const run = this.#runSummary(doc(row));
|
|
1004
|
+
return {
|
|
1005
|
+
run,
|
|
1006
|
+
agent_id: agentId,
|
|
1007
|
+
session: this.#agentRuntime(agent),
|
|
1008
|
+
active_step_id: activeStep === undefined ? null : String(activeStep.id),
|
|
1009
|
+
error: typeof run.error_code === 'string'
|
|
1010
|
+
? { code: run.error_code, message: typeof run.error_message === 'string' ? run.error_message : run.error_code }
|
|
1011
|
+
: null,
|
|
1012
|
+
state_revision: String(this.stateRevision())
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
getRun(projectId, runId) {
|
|
1016
|
+
const row = this.#require('runs', runId);
|
|
1017
|
+
this.#ownedAgent(String(row.agent_id), projectId);
|
|
1018
|
+
return this.#runSummary(doc(row));
|
|
1019
|
+
}
|
|
1020
|
+
getRunGoal(projectId, runId) {
|
|
1021
|
+
const run = this.#require('runs', runId);
|
|
1022
|
+
this.#ownedAgent(String(run.agent_id), projectId);
|
|
1023
|
+
const ids = strings(parseJson(String(run.goal_message_ids_json)));
|
|
1024
|
+
if (ids.length > 0) {
|
|
1025
|
+
const messages = ids.map((messageId) => this.getMessage(projectId, messageId));
|
|
1026
|
+
return { message_ids: ids, content: messages.map((message) => stringField(message.content)).join('\n\n') };
|
|
1027
|
+
}
|
|
1028
|
+
const context = this.#database.prepare('SELECT * FROM context_items WHERE agent_id = ? AND visible = 1 ORDER BY ordinal DESC LIMIT 1').get(run.agent_id);
|
|
1029
|
+
return context === undefined ? { content: '' } : doc(context);
|
|
1030
|
+
}
|
|
1031
|
+
retryAgent(projectId, agentId) {
|
|
1032
|
+
const previous = this.#database.prepare(`SELECT * FROM runs WHERE agent_id = ? AND state = 'TERMINAL' ORDER BY created_at DESC, id DESC LIMIT 1`).get(agentId);
|
|
1033
|
+
if (previous === undefined)
|
|
1034
|
+
fail('RUN_NOT_FOUND', 404, 'The Agent has no terminal Run to retry.');
|
|
1035
|
+
this.getAgent(projectId, agentId);
|
|
1036
|
+
if (previous.outcome !== 'failed' || previous.error_code !== 'COMPACTION_FAILED')
|
|
1037
|
+
fail('RUN_NOT_RETRYABLE', 409, 'Only the latest Compaction failure can be retried.');
|
|
1038
|
+
if (this.#activeRunId(agentId) !== undefined)
|
|
1039
|
+
fail('AGENT_BUSY', 409, 'The Agent already has an active Run.');
|
|
1040
|
+
const runId = id('run');
|
|
1041
|
+
const timestamp = now();
|
|
1042
|
+
return this.#write(projectId, refs(['run', runId]), () => {
|
|
1043
|
+
const initialStepPending = this.#count('SELECT COUNT(*) AS count FROM pending_messages WHERE agent_id = ?', agentId) === 0 ? 1 : 0;
|
|
1044
|
+
this.#database.prepare(`INSERT INTO runs(id, agent_id, goal_message_ids_json, initial_step_pending, state, outcome, error_code, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'READY', NULL, NULL, 0, ?, ?)`)
|
|
1045
|
+
.run(runId, agentId, previous.goal_message_ids_json, initialStepPending, timestamp, timestamp);
|
|
1046
|
+
return this.getRun(projectId, runId);
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
claimRun(projectId, agentId) {
|
|
1050
|
+
const ready = this.#database.prepare(`
|
|
1051
|
+
SELECT * FROM runs WHERE agent_id = ? AND state = 'READY'
|
|
1052
|
+
AND (initial_step_pending = 1 OR EXISTS (SELECT 1 FROM pending_messages WHERE pending_messages.agent_id = runs.agent_id))
|
|
1053
|
+
ORDER BY created_at, id LIMIT 1
|
|
1054
|
+
`).get(agentId);
|
|
1055
|
+
if (ready === undefined)
|
|
1056
|
+
return null;
|
|
1057
|
+
const runId = String(ready.id);
|
|
1058
|
+
const stepId = id('step');
|
|
1059
|
+
const pending = this.#all('SELECT * FROM pending_messages WHERE agent_id = ? ORDER BY ordinal, id', agentId);
|
|
1060
|
+
return this.#write(projectId, refs(['run', runId], ['step', stepId], ['agent', agentId]), () => {
|
|
1061
|
+
const timestamp = now();
|
|
1062
|
+
let turnGroupId;
|
|
1063
|
+
if (pending.length === 0) {
|
|
1064
|
+
turnGroupId = id('turn');
|
|
1065
|
+
this.#insertTurnGroup(turnGroupId, agentId, 'turn', 'OPEN', timestamp);
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
const items = pending.map((entry) => this.#one(`SELECT * FROM context_items WHERE agent_id = ? AND kind = 'message' AND source_id = ? ORDER BY ordinal DESC LIMIT 1`, agentId, entry.message_id));
|
|
1069
|
+
turnGroupId = String(items[0]?.turn_group_id);
|
|
1070
|
+
items.forEach((item, ordinal) => {
|
|
1071
|
+
const sourceGroupId = String(item.turn_group_id);
|
|
1072
|
+
this.#database.prepare('UPDATE context_items SET turn_group_id = ?, turn_group_ordinal = ? WHERE id = ?').run(turnGroupId, ordinal, item.id);
|
|
1073
|
+
if (sourceGroupId !== turnGroupId)
|
|
1074
|
+
this.#database.prepare('DELETE FROM context_turn_groups WHERE id = ?').run(sourceGroupId);
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
const goals = strings(parseJson(String(ready.goal_message_ids_json)));
|
|
1078
|
+
const goalMessageIds = [...new Set([...goals, ...pending.map((entry) => String(entry.message_id))])];
|
|
1079
|
+
const update = this.#database.prepare(`UPDATE runs SET state = 'RUNNING', goal_message_ids_json = ?, initial_step_pending = 0, revision = revision + 1, updated_at = ? WHERE id = ? AND state = 'READY'`)
|
|
1080
|
+
.run(json(goalMessageIds), timestamp, runId);
|
|
1081
|
+
if (update.changes !== 1)
|
|
1082
|
+
fail('RUN_STATE_CONFLICT', 409, 'The Run is no longer ready.');
|
|
1083
|
+
const ordinal = this.#nextOrdinal('steps', 'run_id', runId);
|
|
1084
|
+
this.#database.prepare(`INSERT INTO steps(id, run_id, turn_group_id, ordinal, state, outcome, error_code, created_at, updated_at) VALUES (?, ?, ?, ?, 'RUNNING', NULL, NULL, ?, ?)`)
|
|
1085
|
+
.run(stepId, runId, turnGroupId, ordinal, timestamp, timestamp);
|
|
1086
|
+
this.#database.prepare(`UPDATE agents SET state = 'RUNNING', revision = revision + 1, updated_at = ? WHERE id = ?`).run(timestamp, agentId);
|
|
1087
|
+
return { run: this.getRun(projectId, runId), step: doc(this.#require('steps', stepId)), pending: pending.map(doc) };
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
consumePending(projectId, pendingId) {
|
|
1091
|
+
const pending = this.#require('pending_messages', pendingId);
|
|
1092
|
+
const agentId = String(pending.agent_id);
|
|
1093
|
+
this.#write(projectId, refs(['pending_message', pendingId]), () => {
|
|
1094
|
+
this.#ownedAgent(agentId, projectId);
|
|
1095
|
+
this.#database.prepare('DELETE FROM pending_messages WHERE id = ?').run(pendingId);
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
restorePending(projectId, pending) {
|
|
1099
|
+
const pendingId = stringField(pending.id);
|
|
1100
|
+
const agentId = stringField(pending.agent_id);
|
|
1101
|
+
return this.#write(projectId, refs(['pending_message', pendingId]), () => {
|
|
1102
|
+
this.#ownedAgent(agentId, projectId);
|
|
1103
|
+
const existing = this.#database.prepare('SELECT * FROM pending_messages WHERE id = ?').get(pendingId);
|
|
1104
|
+
if (existing !== undefined)
|
|
1105
|
+
return doc(existing);
|
|
1106
|
+
const rows = this.#all('SELECT id, ordinal FROM pending_messages WHERE agent_id = ? ORDER BY ordinal DESC', agentId);
|
|
1107
|
+
const shift = this.#database.prepare('UPDATE pending_messages SET ordinal = ordinal + 1, revision = revision + 1, updated_at = ? WHERE id = ?');
|
|
1108
|
+
const timestamp = now();
|
|
1109
|
+
for (const row of rows)
|
|
1110
|
+
shift.run(timestamp, row.id);
|
|
1111
|
+
this.#database.prepare(`INSERT INTO pending_messages(id, agent_id, message_id, source_agent_id, content, ordinal, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`)
|
|
1112
|
+
.run(pendingId, agentId, stringField(pending.message_id), pending.source_agent_id ?? null, stringField(pending.content), Number(pending.revision ?? 0) + 1, stringField(pending.created_at), timestamp);
|
|
1113
|
+
return doc(this.#require('pending_messages', pendingId));
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
getStep(projectId, stepId) {
|
|
1117
|
+
const row = this.#require('steps', stepId);
|
|
1118
|
+
const run = this.#require('runs', String(row.run_id));
|
|
1119
|
+
this.#ownedAgent(String(run.agent_id), projectId);
|
|
1120
|
+
return doc(row);
|
|
1121
|
+
}
|
|
1122
|
+
listSteps(projectId, runId, options) {
|
|
1123
|
+
this.getRun(projectId, runId);
|
|
1124
|
+
return this.#page('SELECT * FROM steps WHERE run_id = ?', [runId], options, 'ordinal DESC, id DESC');
|
|
1125
|
+
}
|
|
1126
|
+
createAttempt(projectId, input) {
|
|
1127
|
+
const attemptId = id('attempt');
|
|
1128
|
+
const timestamp = now();
|
|
1129
|
+
return this.#write(projectId, refs(['model_attempt', attemptId]), () => {
|
|
1130
|
+
const agent = this.#require('agents', input.agentId);
|
|
1131
|
+
this.#ownedTask(String(agent.task_id), projectId);
|
|
1132
|
+
if (input.stepId !== null)
|
|
1133
|
+
this.getStep(projectId, input.stepId);
|
|
1134
|
+
const sequence = this.#count('SELECT COUNT(*) AS count FROM model_attempts WHERE agent_id = ? AND kind = ?', input.agentId, input.kind);
|
|
1135
|
+
this.#database.prepare(`INSERT INTO model_attempts(id, agent_id, step_id, kind, sequence, model, state, outcome, profile_id, profile_version, profile_digest, config_digest, context_revision, request_digest, preparation_json, usage_json, error_code, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'PREPARED', NULL, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`)
|
|
1136
|
+
.run(attemptId, input.agentId, input.stepId, input.kind, sequence, input.model, input.profileId, input.profileVersion, input.profileDigest, input.configDigest, input.contextRevision, digest(input.request), json(input.preparation), timestamp, timestamp);
|
|
1137
|
+
const statement = this.#database.prepare('INSERT INTO attempt_capabilities(attempt_id, ordinal, capability_id, schema_json) VALUES (?, ?, ?, ?)');
|
|
1138
|
+
input.capabilities.forEach((entry, ordinal) => statement.run(attemptId, ordinal, entry.id, json(entry.schema)));
|
|
1139
|
+
return this.getAttempt(projectId, attemptId);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
getAttempt(projectId, attemptId) {
|
|
1143
|
+
const row = this.#require('model_attempts', attemptId);
|
|
1144
|
+
this.#ownedAgent(String(row.agent_id), projectId);
|
|
1145
|
+
return { ...doc(row), capabilities: this.#all('SELECT ordinal, capability_id, schema_json FROM attempt_capabilities WHERE attempt_id = ? ORDER BY ordinal', attemptId).map(doc) };
|
|
1146
|
+
}
|
|
1147
|
+
listAttempts(projectId, field, ownerId, options) {
|
|
1148
|
+
if (field === 'agent_id')
|
|
1149
|
+
this.getAgent(projectId, ownerId);
|
|
1150
|
+
else
|
|
1151
|
+
this.getStep(projectId, ownerId);
|
|
1152
|
+
return this.#page(`SELECT * FROM model_attempts WHERE ${field} = ?`, [ownerId], options, 'sequence DESC, id DESC');
|
|
1153
|
+
}
|
|
1154
|
+
transitionAttempt(projectId, attemptId, from, to, outcome = null, usage = null, errorCode = null) {
|
|
1155
|
+
return this.#write(projectId, refs(['model_attempt', attemptId]), () => {
|
|
1156
|
+
const current = this.#require('model_attempts', attemptId);
|
|
1157
|
+
this.#ownedAgent(String(current.agent_id), projectId);
|
|
1158
|
+
const result = this.#database.prepare('UPDATE model_attempts SET state = ?, outcome = ?, usage_json = ?, error_code = ?, updated_at = ? WHERE id = ? AND state = ?')
|
|
1159
|
+
.run(to, outcome, usage === null ? null : json(usage), errorCode, now(), attemptId, from);
|
|
1160
|
+
if (result.changes !== 1)
|
|
1161
|
+
fail('ATTEMPT_STATE_CONFLICT', 409, 'The ModelAttempt state changed.');
|
|
1162
|
+
return this.getAttempt(projectId, attemptId);
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
recordToolOrdinal(projectId, attemptId, ordinal, providerToolId, complete) {
|
|
1166
|
+
this.#write(projectId, refs(['model_attempt', attemptId]), () => {
|
|
1167
|
+
this.getAttempt(projectId, attemptId);
|
|
1168
|
+
this.#database.prepare(`INSERT INTO attempt_tool_ordinals(attempt_id, ordinal, provider_tool_id, complete) VALUES (?, ?, ?, ?) ON CONFLICT(attempt_id, ordinal) DO UPDATE SET provider_tool_id = excluded.provider_tool_id, complete = excluded.complete`)
|
|
1169
|
+
.run(attemptId, ordinal, providerToolId, complete ? 1 : 0);
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
createPartition(projectId, stepId, mode) {
|
|
1173
|
+
const partitionId = id('partition');
|
|
1174
|
+
return this.#write(projectId, refs(['call_partition', partitionId]), () => {
|
|
1175
|
+
this.getStep(projectId, stepId);
|
|
1176
|
+
const ordinal = this.#nextOrdinal('call_partitions', 'step_id', stepId);
|
|
1177
|
+
this.#database.prepare('INSERT INTO call_partitions(id, step_id, ordinal, mode, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
1178
|
+
.run(partitionId, stepId, ordinal, mode, now());
|
|
1179
|
+
return doc(this.#require('call_partitions', partitionId));
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
listPartitions(projectId, stepId, options) {
|
|
1183
|
+
this.getStep(projectId, stepId);
|
|
1184
|
+
return this.#page('SELECT * FROM call_partitions WHERE step_id = ?', [stepId], options, 'ordinal ASC, id ASC');
|
|
1185
|
+
}
|
|
1186
|
+
createCall(projectId, input) {
|
|
1187
|
+
const replay = input.ingress === undefined ? null : this.#ingressReplay(input.ingress);
|
|
1188
|
+
if (replay !== null)
|
|
1189
|
+
return replay;
|
|
1190
|
+
const callId = id('call');
|
|
1191
|
+
const timestamp = now();
|
|
1192
|
+
return this.#write(projectId, refs(['call', callId]), () => {
|
|
1193
|
+
this.getWorkspace(projectId, input.workspaceId);
|
|
1194
|
+
if (input.agentId !== undefined)
|
|
1195
|
+
this.getAgent(projectId, input.agentId);
|
|
1196
|
+
if (input.stepId !== undefined)
|
|
1197
|
+
this.getStep(projectId, input.stepId);
|
|
1198
|
+
if (input.attemptId !== undefined)
|
|
1199
|
+
this.getAttempt(projectId, input.attemptId);
|
|
1200
|
+
this.#database.prepare(`INSERT INTO calls(id, project_id, workspace_id, agent_id, step_id, attempt_id, partition_id, parent_call_id, source, capability_id, provider_call_id, call_ordinal, input_json, effect, policy, state, wait_reason, outcome, outcome_unknown, auto_review_passed, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, 0, ?, ?)`)
|
|
1201
|
+
.run(callId, projectId, input.workspaceId, input.agentId ?? null, input.stepId ?? null, input.attemptId ?? null, input.partitionId ?? null, input.parentCallId ?? null, input.source, input.capabilityId, input.providerCallId ?? null, input.callOrdinal ?? null, json(input.arguments), input.effect, input.policy ?? null, input.state, input.waitReason ?? null, input.autoReviewPassed === undefined ? null : input.autoReviewPassed ? 1 : 0, timestamp, timestamp);
|
|
1202
|
+
const call = this.getCall(projectId, callId);
|
|
1203
|
+
if (input.ingress !== undefined)
|
|
1204
|
+
this.#recordIngress(input.ingress, call);
|
|
1205
|
+
return call;
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* The stored Call row plus its Result, as the Runtime reads it: flat, physical column names,
|
|
1210
|
+
* `state` and `outcome` separate. Every scheduling and dispatch path in `src/runtime` switches on
|
|
1211
|
+
* this shape, so it is deliberately not the documented wire shape -- see `callDetail` and
|
|
1212
|
+
* `callSummary` for those.
|
|
1213
|
+
*/
|
|
1214
|
+
getCall(projectId, callId) {
|
|
1215
|
+
const call = this.#callRow(projectId, callId);
|
|
1216
|
+
const result = this.#database.prepare('SELECT * FROM results WHERE call_id = ?').get(callId);
|
|
1217
|
+
return { ...call, result: result === undefined ? null : doc(result) };
|
|
1218
|
+
}
|
|
1219
|
+
#callRow(projectId, callId) {
|
|
1220
|
+
const row = this.#require('calls', callId);
|
|
1221
|
+
owned(row, 'project_id', projectId);
|
|
1222
|
+
return doc(row);
|
|
1223
|
+
}
|
|
1224
|
+
/** 02A `CallSummary` for one Call. */
|
|
1225
|
+
callSummary(projectId, callId) {
|
|
1226
|
+
const call = this.#callRow(projectId, callId);
|
|
1227
|
+
return this.#callSummaries([call])[0] ?? call;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* 02A `CallDetail`. `concurrency` is absent rather than invented: resource claims live only in the
|
|
1231
|
+
* in-process scheduler (`runtime/scheduler.ts`) and are never persisted, so the Store cannot say
|
|
1232
|
+
* whether a Call was judged concurrency-safe. Reporting `{ safe: true, claims: [] }` would be a
|
|
1233
|
+
* claim no code made.
|
|
1234
|
+
*/
|
|
1235
|
+
callDetail(projectId, callId) {
|
|
1236
|
+
const call = this.#callRow(projectId, callId);
|
|
1237
|
+
const result = this.#database.prepare('SELECT id FROM results WHERE call_id = ?').get(callId);
|
|
1238
|
+
const summary = this.#callSummaries([call])[0] ?? {};
|
|
1239
|
+
const input = jsonRecord(call.input);
|
|
1240
|
+
return {
|
|
1241
|
+
id: call.id ?? null,
|
|
1242
|
+
call_id: call.id ?? null,
|
|
1243
|
+
summary,
|
|
1244
|
+
provider_call_id: call.provider_call_id ?? null,
|
|
1245
|
+
// `input_json` holds the arguments directly, except once a user answer has been merged in, when
|
|
1246
|
+
// it becomes `{ arguments, answer }` -- the same test `answerCall` makes before rewriting it.
|
|
1247
|
+
arguments: input !== null && 'arguments' in input ? input.arguments ?? null : call.input ?? null,
|
|
1248
|
+
user_prompt: summary.user_prompt ?? null,
|
|
1249
|
+
result_id: result === undefined ? null : String(result.id),
|
|
1250
|
+
agent_id: call.agent_id ?? null
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* 02A `CallSummary`. The stored row uses physical column names and splits the lifecycle across
|
|
1255
|
+
* `state`, `wait_reason` and `outcome`; the documented Summary renames `effect`/`policy` to
|
|
1256
|
+
* `effect_scope`/`policy_value` and adds `legacy_state` as the single projection older clients
|
|
1257
|
+
* switch on. `arguments` is deliberately not here -- it belongs to `CallDetail`, and a page of a
|
|
1258
|
+
* hundred Calls carrying every argument object is payload nothing listing them reads.
|
|
1259
|
+
*
|
|
1260
|
+
* `requested_capability` and `capability_id` are one column: nothing records a
|
|
1261
|
+
* requested-but-unresolved Capability, so both project the stored value instead of one of them
|
|
1262
|
+
* guessing. `yielded_at` has no column at all, so it stays null and `legacy_state` never reports
|
|
1263
|
+
* `YIELDED`.
|
|
1264
|
+
*/
|
|
1265
|
+
#callSummary(call, partition, result) {
|
|
1266
|
+
const capability = call.capability_id ?? null;
|
|
1267
|
+
return {
|
|
1268
|
+
id: call.id ?? null,
|
|
1269
|
+
call_id: call.id ?? null,
|
|
1270
|
+
project_id: call.project_id ?? null,
|
|
1271
|
+
source: call.source ?? null,
|
|
1272
|
+
workspace_id: call.workspace_id ?? null,
|
|
1273
|
+
agent_id: call.agent_id ?? null,
|
|
1274
|
+
step_id: call.step_id ?? null,
|
|
1275
|
+
attempt_id: call.attempt_id ?? null,
|
|
1276
|
+
partition_id: call.partition_id ?? null,
|
|
1277
|
+
partition_ordinal: partition?.ordinal ?? null,
|
|
1278
|
+
partition_kind: partition?.mode ?? null,
|
|
1279
|
+
call_ordinal: call.call_ordinal ?? null,
|
|
1280
|
+
requested_capability: capability,
|
|
1281
|
+
capability_id: capability,
|
|
1282
|
+
policy_value: call.policy ?? null,
|
|
1283
|
+
effect_scope: call.effect ?? null,
|
|
1284
|
+
parent_call_id: call.parent_call_id ?? null,
|
|
1285
|
+
state: call.state ?? null,
|
|
1286
|
+
wait_reason: call.wait_reason ?? null,
|
|
1287
|
+
legacy_state: legacyCallState(call),
|
|
1288
|
+
auto_review_passed: call.auto_review_passed ?? null,
|
|
1289
|
+
user_prompt: jsonRecord(jsonRecord(call.input)?.prompt),
|
|
1290
|
+
outcome: call.outcome ?? null,
|
|
1291
|
+
outcome_unknown: call.outcome_unknown === true,
|
|
1292
|
+
yielded_at: null,
|
|
1293
|
+
error_code: result?.error_code ?? null,
|
|
1294
|
+
revision: call.revision ?? null,
|
|
1295
|
+
created_at: call.created_at ?? null,
|
|
1296
|
+
updated_at: call.updated_at ?? null
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Two queries for a whole page rather than two per Call: `partition_ordinal`/`partition_kind` live
|
|
1301
|
+
* on `call_partitions` and `error_code` on `results`, and both are documented `CallSummary` fields.
|
|
1302
|
+
*/
|
|
1303
|
+
#callSummaries(calls) {
|
|
1304
|
+
const partitions = this.#byId('call_partitions', 'id', ids(calls, 'partition_id'));
|
|
1305
|
+
const results = this.#byId('results', 'call_id', ids(calls, 'id'));
|
|
1306
|
+
return calls.map((call) => this.#callSummary(call, typeof call.partition_id === 'string' ? partitions.get(call.partition_id) ?? null : null, typeof call.id === 'string' ? results.get(call.id) ?? null : null));
|
|
1307
|
+
}
|
|
1308
|
+
#byId(table, column, keys) {
|
|
1309
|
+
if (keys.length === 0)
|
|
1310
|
+
return new Map();
|
|
1311
|
+
const rows = this.#all(`SELECT * FROM ${table} WHERE ${column} IN (${keys.map(() => '?').join(', ')})`, ...keys);
|
|
1312
|
+
return new Map(rows.map((row) => [String(row[column]), doc(row)]));
|
|
1313
|
+
}
|
|
1314
|
+
listCalls(projectId, options, filters = {}) {
|
|
1315
|
+
this.getProject(projectId);
|
|
1316
|
+
const clauses = ['project_id = ?'];
|
|
1317
|
+
const parameters = [projectId];
|
|
1318
|
+
if (filters.stepId !== undefined) {
|
|
1319
|
+
clauses.push('step_id = ?');
|
|
1320
|
+
parameters.push(filters.stepId);
|
|
1321
|
+
}
|
|
1322
|
+
if (filters.state !== undefined) {
|
|
1323
|
+
clauses.push('state = ?');
|
|
1324
|
+
parameters.push(filters.state);
|
|
1325
|
+
}
|
|
1326
|
+
const page = this.#page(`SELECT * FROM calls WHERE ${clauses.join(' AND ')}`, parameters, options);
|
|
1327
|
+
return { ...page, items: this.#callSummaries(page.items) };
|
|
1328
|
+
}
|
|
1329
|
+
activeCallIds(projectId, agentId) {
|
|
1330
|
+
this.getAgent(projectId, agentId);
|
|
1331
|
+
return this.#all(`SELECT id FROM calls WHERE project_id = ? AND agent_id = ? AND state != 'TERMINAL' ORDER BY created_at`, projectId, agentId)
|
|
1332
|
+
.map((row) => String(row.id));
|
|
1333
|
+
}
|
|
1334
|
+
queuedDirectCallIds(projectId) {
|
|
1335
|
+
this.getProject(projectId);
|
|
1336
|
+
return this.#all(`SELECT id FROM calls WHERE project_id = ? AND source = 'user' AND step_id IS NULL AND state = 'QUEUED' ORDER BY created_at, id`, projectId)
|
|
1337
|
+
.map((row) => String(row.id));
|
|
1338
|
+
}
|
|
1339
|
+
hasActiveRuntimeObjects(projectId) {
|
|
1340
|
+
this.getProject(projectId);
|
|
1341
|
+
return this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id JOIN tasks t ON t.id = a.task_id WHERE t.project_id = ? AND r.state != 'TERMINAL'`, projectId) > 0
|
|
1342
|
+
|| this.#count(`SELECT COUNT(*) AS count FROM calls WHERE project_id = ? AND state != 'TERMINAL'`, projectId) > 0;
|
|
1343
|
+
}
|
|
1344
|
+
decideCall(projectId, callId, decision, expectedRevision) {
|
|
1345
|
+
return this.#write(projectId, refs(['call', callId]), () => {
|
|
1346
|
+
const current = this.#require('calls', callId);
|
|
1347
|
+
owned(current, 'project_id', projectId);
|
|
1348
|
+
if (expectedRevision !== undefined)
|
|
1349
|
+
this.#expect(current, expectedRevision);
|
|
1350
|
+
const state = String(current.state);
|
|
1351
|
+
if (decision !== 'cancel') {
|
|
1352
|
+
const prompt = jsonRecord(jsonRecord(parseJson(String(current.input_json)))?.prompt);
|
|
1353
|
+
if (state !== 'WAITING' || current.wait_reason !== 'user_input' || prompt?.kind !== 'policy_approval') {
|
|
1354
|
+
fail('CALL_STATE_CONFLICT', 409, 'The Call is not waiting for a policy decision.');
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (decision === 'approve') {
|
|
1358
|
+
this.#database.prepare(`UPDATE calls SET state = 'QUEUED', wait_reason = NULL, revision = revision + 1, updated_at = ? WHERE id = ?`).run(now(), callId);
|
|
1359
|
+
return { call: this.getCall(projectId, callId), result: null };
|
|
1360
|
+
}
|
|
1361
|
+
if (state === 'TERMINAL')
|
|
1362
|
+
fail('CALL_STATE_CONFLICT', 409, 'The Call is already terminal.');
|
|
1363
|
+
const resultId = id('result');
|
|
1364
|
+
const timestamp = now();
|
|
1365
|
+
this.#database.prepare(`UPDATE calls SET state = 'TERMINAL', wait_reason = NULL, outcome = 'cancelled', outcome_unknown = CASE WHEN state = 'RUNNING' THEN 1 ELSE 0 END, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
1366
|
+
.run(timestamp, callId);
|
|
1367
|
+
this.#database.prepare(`INSERT INTO results(id, call_id, outcome, content_json, error_code, error_message, created_at) VALUES (?, ?, ?, '{}', ?, ?, ?)`)
|
|
1368
|
+
.run(resultId, callId, 'cancelled', decision === 'reject' ? 'CALL_REJECTED' : 'CALL_CANCELLED', decision === 'reject' ? 'The user rejected the Call.' : 'The user cancelled the Call.', timestamp);
|
|
1369
|
+
return { call: this.getCall(projectId, callId), result: doc(this.#require('results', resultId)) };
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
answerCall(projectId, callId, expectedRevision, answer) {
|
|
1373
|
+
return this.#write(projectId, refs(['call', callId]), () => {
|
|
1374
|
+
const current = this.#require('calls', callId);
|
|
1375
|
+
owned(current, 'project_id', projectId);
|
|
1376
|
+
this.#expect(current, expectedRevision);
|
|
1377
|
+
if (current.state !== 'WAITING' || current.wait_reason !== 'user_input')
|
|
1378
|
+
fail('CALL_STATE_CONFLICT', 409, 'The Call is not waiting for user input.');
|
|
1379
|
+
const input = parseJson(String(current.input_json));
|
|
1380
|
+
const prompt = jsonRecord(input)?.prompt;
|
|
1381
|
+
validateAnswer(jsonRecord(prompt), answer);
|
|
1382
|
+
if (jsonRecord(prompt)?.kind === 'policy_approval' && policyAnswer(answer) === 'reject') {
|
|
1383
|
+
const resultId = id('result');
|
|
1384
|
+
const timestamp = now();
|
|
1385
|
+
this.#database.prepare(`UPDATE calls SET state = 'TERMINAL', wait_reason = NULL, outcome = 'cancelled', revision = revision + 1, updated_at = ? WHERE id = ?`).run(timestamp, callId);
|
|
1386
|
+
this.#database.prepare(`INSERT INTO results(id, call_id, outcome, content_json, error_code, error_message, created_at) VALUES (?, ?, 'cancelled', '{}', 'CALL_REJECTED', 'The user rejected the Call.', ?)`)
|
|
1387
|
+
.run(resultId, callId, timestamp);
|
|
1388
|
+
return { call: this.getCall(projectId, callId), result: doc(this.#require('results', resultId)) };
|
|
1389
|
+
}
|
|
1390
|
+
const nextInput = typeof input === 'object' && input !== null && !Array.isArray(input) && 'arguments' in input
|
|
1391
|
+
? { ...input, answer }
|
|
1392
|
+
: { arguments: input, answer };
|
|
1393
|
+
this.#database.prepare(`UPDATE calls SET input_json = ?, state = 'QUEUED', wait_reason = NULL, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
1394
|
+
.run(json(nextInput), now(), callId);
|
|
1395
|
+
return { call: this.getCall(projectId, callId), result: null };
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
reviewCall(projectId, callId, approved) {
|
|
1399
|
+
return this.#write(projectId, refs(['call', callId]), () => {
|
|
1400
|
+
const current = this.#require('calls', callId);
|
|
1401
|
+
owned(current, 'project_id', projectId);
|
|
1402
|
+
if (current.state !== 'WAITING' || current.wait_reason !== 'policy_review')
|
|
1403
|
+
fail('CALL_STATE_CONFLICT', 409, 'The Call is not waiting for policy review.');
|
|
1404
|
+
const input = parseJson(String(current.input_json));
|
|
1405
|
+
this.#database.prepare(`UPDATE calls SET input_json = ?, state = ?, wait_reason = ?, auto_review_passed = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
1406
|
+
.run(approved ? json(input) : json({ arguments: input, prompt: policyPrompt(String(current.capability_id)) }), approved ? 'QUEUED' : 'WAITING', approved ? null : 'user_input', approved ? 1 : 0, now(), callId);
|
|
1407
|
+
return this.getCall(projectId, callId);
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
startCall(projectId, callId) {
|
|
1411
|
+
return this.#write(projectId, refs(['call', callId]), () => {
|
|
1412
|
+
const result = this.#database.prepare(`UPDATE calls SET state = 'RUNNING', revision = revision + 1, updated_at = ? WHERE id = ? AND state = 'QUEUED'`).run(now(), callId);
|
|
1413
|
+
if (result.changes !== 1)
|
|
1414
|
+
fail('CALL_STATE_CONFLICT', 409, 'The Call is not queued.');
|
|
1415
|
+
return this.getCall(projectId, callId);
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
waitCall(projectId, callId, prompt) {
|
|
1419
|
+
return this.#write(projectId, refs(['call', callId]), () => {
|
|
1420
|
+
const current = this.#require('calls', callId);
|
|
1421
|
+
owned(current, 'project_id', projectId);
|
|
1422
|
+
if (current.state !== 'RUNNING')
|
|
1423
|
+
fail('CALL_STATE_CONFLICT', 409, 'Only a running Call can request user input.');
|
|
1424
|
+
this.#database.prepare(`UPDATE calls SET state = 'WAITING', wait_reason = 'user_input', input_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
1425
|
+
.run(json({ arguments: parseJson(String(current.input_json)), prompt }), now(), callId);
|
|
1426
|
+
return this.getCall(projectId, callId);
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
completeCall(projectId, callId, outcome, content, errorCode = null, errorMessage = null, artifacts = [], truncated = false) {
|
|
1430
|
+
const resultId = id('result');
|
|
1431
|
+
const timestamp = now();
|
|
1432
|
+
return this.#write(projectId, refs(['call', callId], ['result', resultId]), () => {
|
|
1433
|
+
const update = this.#database.prepare(`UPDATE calls SET state = 'TERMINAL', outcome = ?, outcome_unknown = 0, revision = revision + 1, updated_at = ? WHERE id = ? AND state = 'RUNNING'`)
|
|
1434
|
+
.run(outcome, timestamp, callId);
|
|
1435
|
+
if (update.changes !== 1)
|
|
1436
|
+
fail('CALL_STATE_CONFLICT', 409, 'The Call is not running.');
|
|
1437
|
+
this.#database.prepare(`INSERT INTO results(id, call_id, outcome, content_json, truncated, error_code, error_message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1438
|
+
.run(resultId, callId, outcome, json(content), truncated ? 1 : 0, errorCode, errorMessage, timestamp);
|
|
1439
|
+
const link = this.#database.prepare('INSERT INTO result_artifacts(result_id, artifact_id) VALUES (?, ?)');
|
|
1440
|
+
for (const artifactId of artifacts)
|
|
1441
|
+
link.run(resultId, artifactId);
|
|
1442
|
+
return { call: this.getCall(projectId, callId), result: doc(this.#require('results', resultId)) };
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* The stored Result plus its full Artifact rows, as `includeResult` reads it when it copies a
|
|
1447
|
+
* Result into a Context. Not the wire shape -- see `resultDocument`.
|
|
1448
|
+
*/
|
|
1449
|
+
getResult(projectId, resultId) {
|
|
1450
|
+
const row = this.#require('results', resultId);
|
|
1451
|
+
const call = this.#require('calls', String(row.call_id));
|
|
1452
|
+
owned(call, 'project_id', projectId);
|
|
1453
|
+
const artifacts = this.#all(`SELECT a.* FROM artifacts a JOIN result_artifacts ra ON ra.artifact_id = a.id WHERE ra.result_id = ? ORDER BY a.created_at, a.id`, resultId).map(doc);
|
|
1454
|
+
return { ...doc(row), artifacts };
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* 02A `ResultDocument`. One `content_json` column becomes `text` or `structured` according to its
|
|
1458
|
+
* JSON type, which is the same split `normalizeOutput` makes when it decides what to store: a
|
|
1459
|
+
* Capability producing prose stores a string, one producing records stores an object or array, and
|
|
1460
|
+
* either is `null` here when the payload was too large to inline and became an Artifact instead.
|
|
1461
|
+
* That is why a client reading a large Result has to fall back to `artifact_ids` and ART-01.
|
|
1462
|
+
*
|
|
1463
|
+
* `error_code` stays off the wire here because the documented shape does not carry it; a failed
|
|
1464
|
+
* Call reports its code on `CallSummary.error_code`.
|
|
1465
|
+
*/
|
|
1466
|
+
resultDocument(projectId, resultId) {
|
|
1467
|
+
const result = this.getResult(projectId, resultId);
|
|
1468
|
+
const content = result.content ?? null;
|
|
1469
|
+
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
|
|
1470
|
+
return {
|
|
1471
|
+
id: result.id ?? null,
|
|
1472
|
+
result_id: result.id ?? null,
|
|
1473
|
+
call_id: result.call_id ?? null,
|
|
1474
|
+
outcome: result.outcome ?? null,
|
|
1475
|
+
text: typeof content === 'string' ? content : null,
|
|
1476
|
+
structured: typeof content === 'object' ? content : null,
|
|
1477
|
+
artifact_ids: artifacts
|
|
1478
|
+
.map((artifact) => jsonRecord(artifact)?.id)
|
|
1479
|
+
.filter((value) => typeof value === 'string'),
|
|
1480
|
+
truncated: result.truncated === true,
|
|
1481
|
+
created_at: result.created_at ?? null
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
includeResult(projectId, resultId, agentId, expectedContextRevision) {
|
|
1485
|
+
const result = this.getResult(projectId, resultId);
|
|
1486
|
+
const contextId = id('context');
|
|
1487
|
+
const turnGroupId = id('turn');
|
|
1488
|
+
const timestamp = now();
|
|
1489
|
+
return this.#write(projectId, refs(['turn_group', turnGroupId], ['context_item', contextId]), () => {
|
|
1490
|
+
const agent = this.getAgent(projectId, agentId);
|
|
1491
|
+
if (agent.context_revision !== expectedContextRevision)
|
|
1492
|
+
fail('REVISION_CONFLICT', 409, 'The Context changed before this command was applied.');
|
|
1493
|
+
const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
|
|
1494
|
+
this.#insertTurnGroup(turnGroupId, agentId, 'included', 'COMPLETE', timestamp);
|
|
1495
|
+
this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'result', ?, 'tool', ?, ?, 1, 0, 0, ?, ?)`)
|
|
1496
|
+
.run(contextId, agentId, turnGroupId, resultId, json({ content: result.content ?? null, artifacts: result.artifacts ?? [], truncated: result.truncated === true }), ordinal, timestamp, timestamp);
|
|
1497
|
+
this.#touchContext(agentId);
|
|
1498
|
+
return this.getContextItem(projectId, contextId);
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
createArtifact(projectId, input) {
|
|
1502
|
+
const existing = this.#database.prepare('SELECT * FROM artifacts WHERE project_id = ? AND sha256 = ? AND name = ? AND media_type = ?')
|
|
1503
|
+
.get(projectId, input.sha256, input.name, input.mediaType);
|
|
1504
|
+
if (existing !== undefined)
|
|
1505
|
+
return doc(existing);
|
|
1506
|
+
const artifactId = id('artifact');
|
|
1507
|
+
return this.#write(projectId, refs(['artifact', artifactId]), () => {
|
|
1508
|
+
this.getProject(projectId);
|
|
1509
|
+
this.#database.prepare(`INSERT INTO artifacts(id, project_id, name, media_type, relative_path, sha256, size_bytes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1510
|
+
.run(artifactId, projectId, input.name, input.mediaType, input.relativePath, input.sha256, input.sizeBytes, now());
|
|
1511
|
+
return this.getArtifact(projectId, artifactId);
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
getArtifact(projectId, artifactId) {
|
|
1515
|
+
const row = this.#require('artifacts', artifactId);
|
|
1516
|
+
owned(row, 'project_id', projectId);
|
|
1517
|
+
return doc(row);
|
|
1518
|
+
}
|
|
1519
|
+
listArtifacts(projectId, options) {
|
|
1520
|
+
this.getProject(projectId);
|
|
1521
|
+
// Explicit order: the shared pager defaults to `updated_at`, which this table does not have
|
|
1522
|
+
// (see schema.ts), so the default made every request fail with a SQLite error surfaced as 500.
|
|
1523
|
+
return this.#page('SELECT * FROM artifacts WHERE project_id = ?', [projectId], options, 'created_at DESC, id DESC');
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* 02A `ContextPageQuery.direction`. `backward` walks from the newest item towards the oldest,
|
|
1527
|
+
* which is what a transcript needs: the first page is the latest turn, and paging loads history.
|
|
1528
|
+
* `forward` walks in ordinal order for an audit view. The parameter was ignored and every caller
|
|
1529
|
+
* got ordinal order, so the chat rendered its newest turn first and, past one page, would have
|
|
1530
|
+
* paged away from the conversation instead of into its history.
|
|
1531
|
+
*/
|
|
1532
|
+
listContext(projectId, agentId, options, direction = 'forward') {
|
|
1533
|
+
this.getAgent(projectId, agentId);
|
|
1534
|
+
const order = direction === 'backward' ? 'ordinal DESC, id DESC' : 'ordinal ASC, id ASC';
|
|
1535
|
+
return this.#page('SELECT * FROM context_items WHERE agent_id = ?', [agentId], options, order);
|
|
1536
|
+
}
|
|
1537
|
+
contextSource(projectId, agentId) {
|
|
1538
|
+
return {
|
|
1539
|
+
agent: this.getAgent(projectId, agentId),
|
|
1540
|
+
items: this.#all('SELECT * FROM context_items WHERE agent_id = ? AND visible = 1 ORDER BY ordinal, id', agentId).map(doc),
|
|
1541
|
+
overrides: this.#all('SELECT * FROM context_overrides WHERE agent_id = ? ORDER BY created_at, id', agentId).map(doc),
|
|
1542
|
+
capabilities: this.listCapabilities(projectId, agentId).filter((entry) => entry.loaded === true)
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
getPlan(projectId, agentId) {
|
|
1546
|
+
this.getAgent(projectId, agentId);
|
|
1547
|
+
const row = this.#database.prepare('SELECT * FROM agent_plans WHERE agent_id = ?').get(agentId);
|
|
1548
|
+
return row === undefined ? null : doc(row);
|
|
1549
|
+
}
|
|
1550
|
+
updatePlan(projectId, agentId, items) {
|
|
1551
|
+
return this.#write(projectId, refs(['agent_plan', agentId]), () => {
|
|
1552
|
+
this.getAgent(projectId, agentId);
|
|
1553
|
+
this.#database.prepare(`INSERT INTO agent_plans(agent_id, items_json, revision, updated_at) VALUES (?, ?, 0, ?) ON CONFLICT(agent_id) DO UPDATE SET items_json = excluded.items_json, revision = agent_plans.revision + 1, updated_at = excluded.updated_at`)
|
|
1554
|
+
.run(agentId, json(items), now());
|
|
1555
|
+
return doc(this.#one('SELECT * FROM agent_plans WHERE agent_id = ?', agentId));
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
getContextItem(projectId, contextItemId) {
|
|
1559
|
+
const row = this.#require('context_items', contextItemId);
|
|
1560
|
+
this.#ownedAgent(String(row.agent_id), projectId);
|
|
1561
|
+
return doc(row);
|
|
1562
|
+
}
|
|
1563
|
+
updateContextItem(projectId, contextItemId, expectedRevision, content) {
|
|
1564
|
+
const current = this.#require('context_items', contextItemId);
|
|
1565
|
+
const agentId = String(current.agent_id);
|
|
1566
|
+
return this.#write(projectId, refs(['context_item', contextItemId], ['agent', agentId]), () => {
|
|
1567
|
+
this.#ownedAgent(agentId, projectId);
|
|
1568
|
+
this.#expect(current, expectedRevision);
|
|
1569
|
+
if (Number(current.locked) === 1)
|
|
1570
|
+
fail('CONTEXT_LOCKED', 409, 'The ContextItem is before the latest Compaction boundary.');
|
|
1571
|
+
const timestamp = now();
|
|
1572
|
+
this.#database.prepare('UPDATE context_items SET content = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
1573
|
+
.run(content, timestamp, contextItemId);
|
|
1574
|
+
this.#database.prepare('UPDATE agents SET context_revision = context_revision + 1, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
1575
|
+
.run(timestamp, agentId);
|
|
1576
|
+
return this.getContextItem(projectId, contextItemId);
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
setContextVisibility(projectId, agentId, turnGroupIds, visible, expectedContextRevision) {
|
|
1580
|
+
return this.#write(projectId, turnGroupIds.map((entry) => ({ type: 'turn_group', id: entry })), () => {
|
|
1581
|
+
const agent = this.getAgent(projectId, agentId);
|
|
1582
|
+
if (agent.context_revision !== expectedContextRevision)
|
|
1583
|
+
fail('REVISION_CONFLICT', 409, 'The Context changed before this command was applied.');
|
|
1584
|
+
const statement = this.#database.prepare(`UPDATE context_items SET visible = ?, revision = revision + 1, updated_at = ? WHERE turn_group_id = ? AND agent_id = ? AND locked = 0`);
|
|
1585
|
+
for (const groupId of turnGroupIds) {
|
|
1586
|
+
const group = this.#require('context_turn_groups', groupId);
|
|
1587
|
+
if (group.agent_id !== agentId || group.state !== 'COMPLETE')
|
|
1588
|
+
fail('CONTEXT_GROUP_INVALID', 409, 'A complete TurnGroup is required.');
|
|
1589
|
+
const expected = this.#count('SELECT COUNT(*) AS count FROM context_items WHERE turn_group_id = ?', groupId);
|
|
1590
|
+
if (statement.run(visible ? 1 : 0, now(), groupId, agentId).changes !== expected)
|
|
1591
|
+
fail('CONTEXT_ITEM_CONFLICT', 409, 'A locked TurnGroup cannot change visibility.');
|
|
1592
|
+
}
|
|
1593
|
+
this.#touchContext(agentId);
|
|
1594
|
+
return this.#all(`SELECT * FROM context_items WHERE turn_group_id IN (${turnGroupIds.map(() => '?').join(',')}) ORDER BY ordinal`, ...turnGroupIds).map(doc);
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
reorderContext(projectId, agentId, turnGroupIds, expectedContextRevision) {
|
|
1598
|
+
return this.#write(projectId, turnGroupIds.map((entry) => ({ type: 'turn_group', id: entry })), () => {
|
|
1599
|
+
const agent = this.getAgent(projectId, agentId);
|
|
1600
|
+
if (agent.context_revision !== expectedContextRevision)
|
|
1601
|
+
fail('REVISION_CONFLICT', 409, 'The Context changed before this command was applied.');
|
|
1602
|
+
const current = this.#all(`SELECT g.id FROM context_turn_groups g WHERE g.agent_id = ? AND g.state = 'COMPLETE' AND NOT EXISTS (SELECT 1 FROM context_items i WHERE i.turn_group_id = g.id AND i.locked = 1) ORDER BY g.ordinal`, agentId).map((row) => String(row.id));
|
|
1603
|
+
if (current.length !== turnGroupIds.length || current.some((entry) => !turnGroupIds.includes(entry)) || new Set(turnGroupIds).size !== turnGroupIds.length)
|
|
1604
|
+
fail('CONTEXT_SET_MISMATCH', 409, 'The complete unlocked TurnGroup order is required.');
|
|
1605
|
+
const lockedCount = this.#count('SELECT COUNT(*) AS count FROM context_items WHERE agent_id = ? AND locked = 1', agentId);
|
|
1606
|
+
const lockedGroups = this.#count(`SELECT COUNT(*) AS count FROM context_turn_groups g WHERE g.agent_id = ? AND EXISTS (SELECT 1 FROM context_items i WHERE i.turn_group_id = g.id AND i.locked = 1)`, agentId);
|
|
1607
|
+
const move = this.#database.prepare('UPDATE context_items SET ordinal = ordinal + 1000000000 WHERE turn_group_id = ?');
|
|
1608
|
+
for (const groupId of turnGroupIds)
|
|
1609
|
+
move.run(groupId);
|
|
1610
|
+
const updateItem = this.#database.prepare('UPDATE context_items SET ordinal = ?, revision = revision + 1, updated_at = ? WHERE id = ?');
|
|
1611
|
+
const updateGroup = this.#database.prepare('UPDATE context_turn_groups SET ordinal = ?, revision = revision + 1 WHERE id = ?');
|
|
1612
|
+
let ordinal = lockedCount;
|
|
1613
|
+
for (const [groupOffset, groupId] of turnGroupIds.entries()) {
|
|
1614
|
+
updateGroup.run(lockedGroups + groupOffset, groupId);
|
|
1615
|
+
for (const row of this.#all('SELECT id FROM context_items WHERE turn_group_id = ? ORDER BY turn_group_ordinal', groupId)) {
|
|
1616
|
+
updateItem.run(ordinal, now(), row.id);
|
|
1617
|
+
ordinal += 1;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
this.#touchContext(agentId);
|
|
1621
|
+
return this.#all('SELECT * FROM context_items WHERE agent_id = ? ORDER BY ordinal', agentId).map(doc);
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
listOverrides(projectId, agentId, options) {
|
|
1625
|
+
this.getAgent(projectId, agentId);
|
|
1626
|
+
return this.#page('SELECT * FROM context_overrides WHERE agent_id = ?', [agentId], options);
|
|
1627
|
+
}
|
|
1628
|
+
getOverride(projectId, overrideId) {
|
|
1629
|
+
const row = this.#require('context_overrides', overrideId);
|
|
1630
|
+
this.#ownedAgent(String(row.agent_id), projectId);
|
|
1631
|
+
return doc(row);
|
|
1632
|
+
}
|
|
1633
|
+
createOverride(projectId, agentId, targetKind, targetId, instructions) {
|
|
1634
|
+
const overrideId = id('override');
|
|
1635
|
+
const timestamp = now();
|
|
1636
|
+
return this.#write(projectId, refs(['context_override', overrideId], ['agent', agentId]), () => {
|
|
1637
|
+
this.getAgent(projectId, agentId);
|
|
1638
|
+
this.#database.prepare(`INSERT INTO context_overrides(id, agent_id, target_kind, target_id, instructions, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`)
|
|
1639
|
+
.run(overrideId, agentId, targetKind, targetId, instructions, timestamp, timestamp);
|
|
1640
|
+
this.#touchContext(agentId);
|
|
1641
|
+
return this.getOverride(projectId, overrideId);
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
updateOverride(projectId, overrideId, expectedRevision, instructions) {
|
|
1645
|
+
const current = this.#require('context_overrides', overrideId);
|
|
1646
|
+
const agentId = String(current.agent_id);
|
|
1647
|
+
return this.#write(projectId, refs(['context_override', overrideId], ['agent', agentId]), () => {
|
|
1648
|
+
this.#ownedAgent(agentId, projectId);
|
|
1649
|
+
this.#expect(current, expectedRevision);
|
|
1650
|
+
this.#database.prepare('UPDATE context_overrides SET instructions = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
1651
|
+
.run(instructions, now(), overrideId);
|
|
1652
|
+
this.#touchContext(agentId);
|
|
1653
|
+
return this.getOverride(projectId, overrideId);
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
deleteOverride(projectId, overrideId, expectedRevision) {
|
|
1657
|
+
const current = this.#require('context_overrides', overrideId);
|
|
1658
|
+
const agentId = String(current.agent_id);
|
|
1659
|
+
this.#write(projectId, refs(['context_override', overrideId], ['agent', agentId]), () => {
|
|
1660
|
+
this.#ownedAgent(agentId, projectId);
|
|
1661
|
+
this.#expect(current, expectedRevision);
|
|
1662
|
+
this.#database.prepare('DELETE FROM context_overrides WHERE id = ?').run(overrideId);
|
|
1663
|
+
this.#touchContext(agentId);
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
listTurnGroups(projectId, agentId, options) {
|
|
1667
|
+
this.getAgent(projectId, agentId);
|
|
1668
|
+
return this.#page('SELECT * FROM context_turn_groups WHERE agent_id = ?', [agentId], options, 'ordinal ASC, id ASC');
|
|
1669
|
+
}
|
|
1670
|
+
listProjectionDecisions(projectId, agentId, options) {
|
|
1671
|
+
this.getAgent(projectId, agentId);
|
|
1672
|
+
return this.#page('SELECT * FROM projection_decisions WHERE agent_id = ?', [agentId], options, 'created_at DESC, id DESC');
|
|
1673
|
+
}
|
|
1674
|
+
resetProjection(projectId, agentId, expectedContextRevision, stages) {
|
|
1675
|
+
return this.#write(projectId, refs(['agent', agentId]), () => {
|
|
1676
|
+
const agent = this.getAgent(projectId, agentId);
|
|
1677
|
+
if (agent.context_revision !== expectedContextRevision)
|
|
1678
|
+
fail('REVISION_CONFLICT', 409, 'The Context changed before this command was applied.');
|
|
1679
|
+
if (stages === undefined)
|
|
1680
|
+
this.#database.prepare('DELETE FROM projection_decisions WHERE agent_id = ?').run(agentId);
|
|
1681
|
+
else
|
|
1682
|
+
this.#database.prepare(`DELETE FROM projection_decisions WHERE agent_id = ? AND stage IN (${stages.map(() => '?').join(',')})`).run(agentId, ...stages);
|
|
1683
|
+
return { agent_id: agentId, context_revision: agent.context_revision };
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
appendProjectionDecision(projectId, agentId, attemptId, stage, sourceTokens, projectedTokens, details) {
|
|
1687
|
+
const decisionId = id('projection');
|
|
1688
|
+
return this.#write(projectId, refs(['projection_decision', decisionId]), () => {
|
|
1689
|
+
this.getAgent(projectId, agentId);
|
|
1690
|
+
this.#database.prepare(`INSERT INTO projection_decisions(id, agent_id, attempt_id, stage, source_tokens, projected_tokens, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1691
|
+
.run(decisionId, agentId, attemptId, stage, sourceTokens, projectedTokens, json(details), now());
|
|
1692
|
+
return doc(this.#require('projection_decisions', decisionId));
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
compactContext(projectId, agentId, summary, preserveFromOrdinal) {
|
|
1696
|
+
const contextId = id('context');
|
|
1697
|
+
const turnGroupId = id('turn');
|
|
1698
|
+
const timestamp = now();
|
|
1699
|
+
return this.#write(projectId, refs(['turn_group', turnGroupId], ['context_item', contextId], ['agent', agentId]), () => {
|
|
1700
|
+
this.getAgent(projectId, agentId);
|
|
1701
|
+
this.#database.prepare('UPDATE context_items SET locked = 1, revision = revision + 1, updated_at = ? WHERE agent_id = ? AND ordinal < ?')
|
|
1702
|
+
.run(timestamp, agentId, preserveFromOrdinal);
|
|
1703
|
+
const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
|
|
1704
|
+
this.#insertTurnGroup(turnGroupId, agentId, 'compaction', 'COMPLETE', timestamp);
|
|
1705
|
+
this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'compaction', ?, 'system', ?, ?, 1, 0, 0, ?, ?)`)
|
|
1706
|
+
.run(contextId, agentId, turnGroupId, contextId, summary, ordinal, timestamp, timestamp);
|
|
1707
|
+
this.#touchContext(agentId);
|
|
1708
|
+
return this.getContextItem(projectId, contextId);
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
forkContext(projectId, sourceAgentId, targetAgentId, throughOrdinal) {
|
|
1712
|
+
return this.#write(projectId, refs(['agent', targetAgentId]), () => {
|
|
1713
|
+
this.getAgent(projectId, sourceAgentId);
|
|
1714
|
+
this.getAgent(projectId, targetAgentId);
|
|
1715
|
+
return this.#copyContextRange(sourceAgentId, targetAgentId, throughOrdinal, now());
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
createContextFork(projectId, sourceAgentId, input) {
|
|
1719
|
+
const targetAgentId = id('agent');
|
|
1720
|
+
return this.#write(projectId, refs(['agent', targetAgentId]), () => {
|
|
1721
|
+
const source = this.getAgent(projectId, sourceAgentId);
|
|
1722
|
+
if (source.context_revision !== input.expectedContextRevision)
|
|
1723
|
+
fail('FORK_SOURCE_CHANGED', 409, 'The source Context changed before Fork.');
|
|
1724
|
+
const boundary = this.#require('context_turn_groups', input.throughTurnGroupId);
|
|
1725
|
+
if (boundary.agent_id !== sourceAgentId || boundary.state !== 'COMPLETE')
|
|
1726
|
+
fail('CONTEXT_GROUP_INVALID', 409, 'Context Fork requires a complete source TurnGroup.');
|
|
1727
|
+
if (this.#count('SELECT COUNT(*) AS count FROM context_items WHERE turn_group_id = ? AND locked = 1', input.throughTurnGroupId) !== 0)
|
|
1728
|
+
fail('CONTEXT_LOCKED', 409, 'The Context Fork boundary is locked.');
|
|
1729
|
+
const throughOrdinal = Number(this.#one('SELECT MAX(ordinal) AS ordinal FROM context_items WHERE turn_group_id = ?', input.throughTurnGroupId).ordinal);
|
|
1730
|
+
const taskId = stringField(source.task_id);
|
|
1731
|
+
const timestamp = now();
|
|
1732
|
+
const parentAgentId = source.parent_agent_id === null ? undefined : stringField(source.parent_agent_id);
|
|
1733
|
+
let sessionId = stringField(source.session_id);
|
|
1734
|
+
if (parentAgentId === undefined) {
|
|
1735
|
+
sessionId = id('session');
|
|
1736
|
+
this.#database.prepare(`INSERT INTO sessions(id, task_id, title, archived_at, revision, created_at, updated_at) VALUES (?, ?, ?, NULL, 0, ?, ?)`)
|
|
1737
|
+
.run(sessionId, taskId, input.name, timestamp, timestamp);
|
|
1738
|
+
}
|
|
1739
|
+
const capabilities = Object.fromEntries(this.#all('SELECT capability_id, policy FROM capability_assignments WHERE agent_id = ?', sourceAgentId).map((row) => [String(row.capability_id), String(row.policy)]));
|
|
1740
|
+
const loadedCapabilities = this.#all('SELECT capability_id FROM capability_loads WHERE agent_id = ?', sourceAgentId).map((row) => String(row.capability_id));
|
|
1741
|
+
this.#insertAgent(projectId, targetAgentId, {
|
|
1742
|
+
taskId,
|
|
1743
|
+
sessionId,
|
|
1744
|
+
workspaceId: stringField(source.workspace_id),
|
|
1745
|
+
...(parentAgentId === undefined ? {} : { parentAgentId }),
|
|
1746
|
+
name: input.name,
|
|
1747
|
+
model: input.model,
|
|
1748
|
+
modelReasoningEffort: input.modelReasoningEffort,
|
|
1749
|
+
policyPreset: stringField(source.policy_preset),
|
|
1750
|
+
capabilities,
|
|
1751
|
+
loadedCapabilities
|
|
1752
|
+
}, timestamp);
|
|
1753
|
+
this.#copyContextRange(sourceAgentId, targetAgentId, throughOrdinal, timestamp);
|
|
1754
|
+
return this.getAgent(projectId, targetAgentId);
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
appendAssistantMessage(projectId, agentId, stepId, content) {
|
|
1758
|
+
const messageId = id('message');
|
|
1759
|
+
const contextId = id('context');
|
|
1760
|
+
const timestamp = now();
|
|
1761
|
+
return this.#write(projectId, refs(['message', messageId], ['context_item', contextId]), () => {
|
|
1762
|
+
this.getAgent(projectId, agentId);
|
|
1763
|
+
const turnGroupId = stringField(this.getStep(projectId, stepId).turn_group_id);
|
|
1764
|
+
this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, NULL, 'assistant', ?, NULL, ?)`)
|
|
1765
|
+
.run(messageId, agentId, content, timestamp);
|
|
1766
|
+
const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
|
|
1767
|
+
const groupOrdinal = this.#nextOrdinal('context_items', 'turn_group_id', turnGroupId);
|
|
1768
|
+
this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'message', ?, 'assistant', ?, ?, 1, 0, 0, ?, ?)`)
|
|
1769
|
+
.run(contextId, agentId, turnGroupId, groupOrdinal, messageId, content, ordinal, timestamp, timestamp);
|
|
1770
|
+
this.#touchContext(agentId);
|
|
1771
|
+
return this.getMessage(projectId, messageId);
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
appendToolCalls(projectId, agentId, stepId, calls) {
|
|
1775
|
+
const contextId = id('context');
|
|
1776
|
+
const timestamp = now();
|
|
1777
|
+
return this.#write(projectId, refs(['context_item', contextId]), () => {
|
|
1778
|
+
this.getAgent(projectId, agentId);
|
|
1779
|
+
const turnGroupId = stringField(this.getStep(projectId, stepId).turn_group_id);
|
|
1780
|
+
const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
|
|
1781
|
+
const groupOrdinal = this.#nextOrdinal('context_items', 'turn_group_id', turnGroupId);
|
|
1782
|
+
this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'tool_calls', ?, 'assistant', ?, ?, 1, 0, 0, ?, ?)`)
|
|
1783
|
+
.run(contextId, agentId, turnGroupId, groupOrdinal, contextId, json(calls), ordinal, timestamp, timestamp);
|
|
1784
|
+
this.#touchContext(agentId);
|
|
1785
|
+
return this.getContextItem(projectId, contextId);
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* `errorMessage` is what the failure actually said. `error_code` alone made a Provider rejection, a TLS
|
|
1790
|
+
* fault and a malformed Tool block all read as one `RUNTIME_FAILED` with nothing to act on.
|
|
1791
|
+
*/
|
|
1792
|
+
finishStep(projectId, stepId, outcome, errorCode = null, errorMessage = null) {
|
|
1793
|
+
const step = this.#require('steps', stepId);
|
|
1794
|
+
const runId = String(step.run_id);
|
|
1795
|
+
const run = this.#require('runs', runId);
|
|
1796
|
+
const agentId = String(run.agent_id);
|
|
1797
|
+
return this.#write(projectId, refs(['step', stepId], ['run', runId], ['agent', agentId]), () => {
|
|
1798
|
+
const timestamp = now();
|
|
1799
|
+
this.#database.prepare(`UPDATE steps SET state = 'TERMINAL', outcome = ?, error_code = ?, error_message = ?, updated_at = ? WHERE id = ? AND state != 'TERMINAL'`)
|
|
1800
|
+
.run(outcome, errorCode, errorMessage, timestamp, stepId);
|
|
1801
|
+
const hasPending = this.#count('SELECT COUNT(*) AS count FROM pending_messages WHERE agent_id = ?', agentId) > 0;
|
|
1802
|
+
const continueRun = outcome === 'success' && hasPending;
|
|
1803
|
+
this.#database.prepare(`UPDATE runs SET state = ?, outcome = ?, error_code = ?, error_message = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
1804
|
+
.run(continueRun ? 'READY' : 'TERMINAL', continueRun ? null : outcome, continueRun ? null : errorCode, continueRun ? null : errorMessage, timestamp, runId);
|
|
1805
|
+
if (!continueRun && hasPending && errorCode !== 'COMPACTION_FAILED') {
|
|
1806
|
+
this.#database.prepare(`INSERT INTO runs(id, agent_id, goal_message_ids_json, state, outcome, error_code, revision, created_at, updated_at) VALUES (?, ?, '[]', 'READY', NULL, NULL, 0, ?, ?)`)
|
|
1807
|
+
.run(id('run'), agentId, timestamp, timestamp);
|
|
1808
|
+
}
|
|
1809
|
+
this.#database.prepare(`UPDATE agents SET state = 'IDLE', revision = revision + 1, updated_at = ? WHERE id = ?`)
|
|
1810
|
+
.run(timestamp, agentId);
|
|
1811
|
+
this.#database.prepare(`UPDATE context_turn_groups SET state = ?, revision = revision + 1, completed_at = ? WHERE id = ? AND state = 'OPEN'`)
|
|
1812
|
+
.run(outcome === 'success' ? 'COMPLETE' : 'ABORTED', timestamp, step.turn_group_id);
|
|
1813
|
+
return { step: this.getStep(projectId, stepId), run: this.getRun(projectId, runId), agent: this.getAgent(projectId, agentId) };
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
continueRun(projectId, stepId) {
|
|
1817
|
+
const step = this.#require('steps', stepId);
|
|
1818
|
+
const runId = String(step.run_id);
|
|
1819
|
+
const run = this.#require('runs', runId);
|
|
1820
|
+
const nextStepId = id('step');
|
|
1821
|
+
return this.#write(projectId, refs(['step', stepId], ['step', nextStepId], ['run', runId]), () => {
|
|
1822
|
+
const timestamp = now();
|
|
1823
|
+
const update = this.#database.prepare(`UPDATE steps SET state = 'TERMINAL', outcome = 'success', updated_at = ? WHERE id = ? AND state != 'TERMINAL'`)
|
|
1824
|
+
.run(timestamp, stepId);
|
|
1825
|
+
if (update.changes !== 1)
|
|
1826
|
+
fail('STEP_STATE_CONFLICT', 409, 'The Step is already terminal.');
|
|
1827
|
+
const ordinal = this.#nextOrdinal('steps', 'run_id', runId);
|
|
1828
|
+
this.#database.prepare(`INSERT INTO steps(id, run_id, turn_group_id, ordinal, state, outcome, error_code, created_at, updated_at) VALUES (?, ?, ?, ?, 'RUNNING', NULL, NULL, ?, ?)`)
|
|
1829
|
+
.run(nextStepId, runId, step.turn_group_id, ordinal, timestamp, timestamp);
|
|
1830
|
+
this.#database.prepare(`UPDATE runs SET state = 'RUNNING', revision = revision + 1, updated_at = ? WHERE id = ?`).run(timestamp, runId);
|
|
1831
|
+
this.#ownedAgent(String(run.agent_id), projectId);
|
|
1832
|
+
return doc(this.#require('steps', nextStepId));
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
yieldResults(projectId, agentId, callIds) {
|
|
1836
|
+
const timestamp = now();
|
|
1837
|
+
return this.#write(projectId, callIds.map((entry) => ({ type: 'result', id: entry })), () => {
|
|
1838
|
+
this.getAgent(projectId, agentId);
|
|
1839
|
+
const rows = callIds.map((callId) => {
|
|
1840
|
+
const call = this.#require('calls', callId);
|
|
1841
|
+
if (call.state !== 'TERMINAL')
|
|
1842
|
+
fail('CALL_STATE_CONFLICT', 409, 'All Calls must be terminal before Result Yield.');
|
|
1843
|
+
const result = this.#database.prepare('SELECT * FROM results WHERE call_id = ?').get(callId);
|
|
1844
|
+
if (result === undefined)
|
|
1845
|
+
fail('RESULT_NOT_FOUND', 503, 'A terminal Call has no Result.');
|
|
1846
|
+
return { call, result };
|
|
1847
|
+
}).sort((left, right) => Number(left.call.call_ordinal ?? 0) - Number(right.call.call_ordinal ?? 0));
|
|
1848
|
+
const yielded = [];
|
|
1849
|
+
for (const { call, result } of rows) {
|
|
1850
|
+
if (typeof call.provider_call_id !== 'string')
|
|
1851
|
+
fail('DATABASE_CORRUPT', 503, 'An LLM Call has no Provider Call ID.');
|
|
1852
|
+
if (typeof call.step_id !== 'string')
|
|
1853
|
+
fail('DATABASE_CORRUPT', 503, 'An LLM Call has no Step.');
|
|
1854
|
+
const turnGroupId = String(this.#require('steps', call.step_id).turn_group_id);
|
|
1855
|
+
const contextId = id('context');
|
|
1856
|
+
const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
|
|
1857
|
+
const groupOrdinal = this.#nextOrdinal('context_items', 'turn_group_id', turnGroupId);
|
|
1858
|
+
const artifacts = this.#all('SELECT artifact_id FROM result_artifacts WHERE result_id = ? ORDER BY artifact_id', result.id).map((row) => String(row.artifact_id));
|
|
1859
|
+
const content = json({ content: parseJson(String(result.content_json)), artifacts, truncated: Number(result.truncated) === 1 });
|
|
1860
|
+
this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, tool_call_id, tool_name, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'result', ?, 'tool', ?, ?, ?, ?, 1, 0, 0, ?, ?)`)
|
|
1861
|
+
.run(contextId, agentId, turnGroupId, groupOrdinal, String(result.id), content, call.provider_call_id, String(call.capability_id), ordinal, timestamp, timestamp);
|
|
1862
|
+
yielded.push({ ...doc(result), tool_call_id: call.provider_call_id, tool_name: String(call.capability_id) });
|
|
1863
|
+
}
|
|
1864
|
+
this.#touchContext(agentId);
|
|
1865
|
+
return yielded;
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
cancelAgent(projectId, agentId) {
|
|
1869
|
+
return this.#write(projectId, refs(['agent', agentId]), () => {
|
|
1870
|
+
this.getAgent(projectId, agentId);
|
|
1871
|
+
const timestamp = now();
|
|
1872
|
+
this.#database.prepare(`UPDATE model_attempts SET state = 'TERMINAL', outcome = 'cancelled', error_code = 'USER_CANCELLED', updated_at = ? WHERE agent_id = ? AND state != 'TERMINAL'`).run(timestamp, agentId);
|
|
1873
|
+
this.#database.prepare(`UPDATE calls SET state = 'TERMINAL', outcome = 'cancelled', outcome_unknown = CASE WHEN state = 'RUNNING' THEN 1 ELSE 0 END, revision = revision + 1, updated_at = ? WHERE agent_id = ? AND state != 'TERMINAL'`).run(timestamp, agentId);
|
|
1874
|
+
this.#database.prepare(`UPDATE steps SET state = 'TERMINAL', outcome = 'cancelled', error_code = 'USER_CANCELLED', updated_at = ? WHERE run_id IN (SELECT id FROM runs WHERE agent_id = ?) AND state != 'TERMINAL'`).run(timestamp, agentId);
|
|
1875
|
+
this.#database.prepare(`UPDATE runs SET state = 'TERMINAL', outcome = 'cancelled', error_code = 'USER_CANCELLED', revision = revision + 1, updated_at = ? WHERE agent_id = ? AND state != 'TERMINAL'`).run(timestamp, agentId);
|
|
1876
|
+
this.#database.prepare(`UPDATE agents SET state = 'IDLE', revision = revision + 1, updated_at = ? WHERE id = ?`).run(timestamp, agentId);
|
|
1877
|
+
this.#database.prepare(`UPDATE context_turn_groups SET state = 'ABORTED', revision = revision + 1, completed_at = ? WHERE agent_id = ? AND state = 'OPEN'`).run(timestamp, agentId);
|
|
1878
|
+
return this.#all('SELECT * FROM runs WHERE agent_id = ? ORDER BY created_at DESC', agentId).map(doc);
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
runTimeline(projectId, runId, options) {
|
|
1882
|
+
this.getRun(projectId, runId);
|
|
1883
|
+
return this.#page(`
|
|
1884
|
+
SELECT 'step' AS entity_type, id, created_at, state, outcome, error_code FROM steps WHERE run_id = ?
|
|
1885
|
+
UNION ALL
|
|
1886
|
+
SELECT 'call' AS entity_type, c.id, c.created_at, c.state, c.outcome, NULL AS error_code
|
|
1887
|
+
FROM calls c JOIN steps s ON s.id = c.step_id WHERE s.run_id = ?
|
|
1888
|
+
`, [runId, runId], options, 'created_at ASC, id ASC');
|
|
1889
|
+
}
|
|
1890
|
+
stepOutput(projectId, stepId) {
|
|
1891
|
+
this.getStep(projectId, stepId);
|
|
1892
|
+
const calls = this.#all('SELECT * FROM calls WHERE step_id = ? ORDER BY call_ordinal, id', stepId).map(doc);
|
|
1893
|
+
const results = this.#all('SELECT r.* FROM results r JOIN calls c ON c.id = r.call_id WHERE c.step_id = ? ORDER BY c.call_ordinal, c.id', stepId).map(doc);
|
|
1894
|
+
return { calls, results };
|
|
1895
|
+
}
|
|
1896
|
+
storageStats() {
|
|
1897
|
+
const pageCount = Number(this.#one('PRAGMA page_count').page_count);
|
|
1898
|
+
const pageSize = Number(this.#one('PRAGMA page_size').page_size);
|
|
1899
|
+
const tables = ['projects', 'workspaces', 'tasks', 'sessions', 'agents', 'messages', 'runs', 'steps', 'model_attempts', 'calls', 'results', 'artifacts', 'context_items'];
|
|
1900
|
+
return {
|
|
1901
|
+
bytes: pageCount * pageSize,
|
|
1902
|
+
tables: Object.fromEntries(tables.map((table) => [table, this.#count(`SELECT COUNT(*) AS count FROM ${table}`)]))
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
exportState() {
|
|
1906
|
+
const tables = [
|
|
1907
|
+
'instance', 'projects', 'workspaces', 'workspace_paths', 'tasks', 'sessions', 'agents', 'messages',
|
|
1908
|
+
'pending_messages', 'runs', 'steps', 'model_attempts', 'attempt_capabilities', 'attempt_tool_ordinals',
|
|
1909
|
+
'call_partitions', 'calls', 'results', 'artifacts', 'result_artifacts', 'context_turn_groups',
|
|
1910
|
+
'context_items', 'context_overrides', 'projection_decisions', 'capability_assignments', 'capability_loads',
|
|
1911
|
+
'agent_plans', 'ingress_requests', 'side_effect_ledgers'
|
|
1912
|
+
];
|
|
1913
|
+
return Object.fromEntries(tables.map((table) => [table, this.#all(`SELECT * FROM ${table}`).map(rawJson)]));
|
|
1914
|
+
}
|
|
1915
|
+
purgeArchived(projectId, before) {
|
|
1916
|
+
return this.#write(projectId, refs(['maintenance', projectId]), () => {
|
|
1917
|
+
const tasks = this.#all('SELECT id FROM tasks WHERE project_id = ? AND archived_at IS NOT NULL AND archived_at < ?', projectId, before).map((row) => String(row.id));
|
|
1918
|
+
return { purged_tasks: tasks.length, ...this.#purgeTaskRows(tasks) };
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
snapshot(projectId, maxEntities) {
|
|
1922
|
+
const project = this.getProject(projectId);
|
|
1923
|
+
const workspaces = this.listWorkspaces(projectId);
|
|
1924
|
+
const tasks = this.#all('SELECT * FROM tasks WHERE project_id = ? AND archived_at IS NULL ORDER BY updated_at DESC', projectId).map(doc);
|
|
1925
|
+
const sessions = this.#all(`SELECT s.* FROM sessions s JOIN tasks t ON t.id = s.task_id WHERE t.project_id = ? AND s.archived_at IS NULL ORDER BY s.updated_at DESC`, projectId).map(doc);
|
|
1926
|
+
const agents = this.#all(`SELECT a.* FROM agents a JOIN tasks t ON t.id = a.task_id WHERE t.project_id = ? AND a.state != 'STOPPED' ORDER BY a.updated_at DESC`, projectId).map((row) => this.#agent(row));
|
|
1927
|
+
const runs = this.#all(`SELECT r.* FROM runs r JOIN agents a ON a.id = r.agent_id JOIN tasks t ON t.id = a.task_id WHERE t.project_id = ? AND r.state != 'TERMINAL' ORDER BY r.updated_at DESC`, projectId).map(doc);
|
|
1928
|
+
const calls = this.#all(`SELECT * FROM calls WHERE project_id = ? AND state != 'TERMINAL' ORDER BY updated_at DESC`, projectId).map(doc);
|
|
1929
|
+
const count = workspaces.length + tasks.length + sessions.length + agents.length + runs.length + calls.length;
|
|
1930
|
+
if (count > maxEntities)
|
|
1931
|
+
fail('SNAPSHOT_TOO_LARGE', 503, 'The runtime Snapshot exceeds its configured bound.', { count, maximum: maxEntities });
|
|
1932
|
+
return { revision: this.stateRevision(), project, workspaces, tasks, sessions, agents, runs, calls };
|
|
1933
|
+
}
|
|
1934
|
+
#ingressReplay(input) {
|
|
1935
|
+
const row = this.#database.prepare('SELECT operation, request_digest, response_json FROM ingress_requests WHERE client_id = ? AND key = ?')
|
|
1936
|
+
.get(input.clientId, input.key);
|
|
1937
|
+
if (row === undefined)
|
|
1938
|
+
return null;
|
|
1939
|
+
if (row.operation !== input.operation || row.request_digest !== input.digest) {
|
|
1940
|
+
fail('IDEMPOTENCY_CONFLICT', 409, 'The Idempotency-Key was already used for a different request.');
|
|
1941
|
+
}
|
|
1942
|
+
const value = parseJson(String(row.response_json));
|
|
1943
|
+
if (!isJsonObject(value))
|
|
1944
|
+
fail('DATABASE_CORRUPT', 503, 'An ingress response is invalid.');
|
|
1945
|
+
return value;
|
|
1946
|
+
}
|
|
1947
|
+
#recordIngress(input, response) {
|
|
1948
|
+
this.#database.prepare('INSERT INTO ingress_requests(client_id, key, operation, request_digest, response_json, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
1949
|
+
.run(input.clientId, input.key, input.operation, input.digest, json(response), now());
|
|
1950
|
+
}
|
|
1951
|
+
#write(projectId, changes, command) {
|
|
1952
|
+
const transaction = this.#database.transaction(() => {
|
|
1953
|
+
const value = command();
|
|
1954
|
+
this.#database.prepare('UPDATE instance SET state_revision = state_revision + 1 WHERE id = 1').run();
|
|
1955
|
+
return { value, stateRevision: this.stateRevision() };
|
|
1956
|
+
});
|
|
1957
|
+
const result = transaction();
|
|
1958
|
+
this.#eventLog?.append(result.stateRevision, this.exportState());
|
|
1959
|
+
// Resolved after the transaction commits: the owner and revision are read from the state the
|
|
1960
|
+
// subscriber is about to see, and an entity that is gone is reported as deleted.
|
|
1961
|
+
const change = {
|
|
1962
|
+
projectId,
|
|
1963
|
+
stateRevision: result.stateRevision,
|
|
1964
|
+
changes: changes.map((entry) => this.#describeChange(entry))
|
|
1965
|
+
};
|
|
1966
|
+
this.#events.publish(change);
|
|
1967
|
+
return result.value;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* 07 `ChangedEntityRef`. The event carried only `{ type, id }`, so `read_route` was undefined and
|
|
1971
|
+
* a subscriber had nothing to re-read -- live invalidation could not work at all -- while the
|
|
1972
|
+
* absent `owner_id` threw inside the subscriber's handler and stopped the stream outright.
|
|
1973
|
+
*
|
|
1974
|
+
* `change` distinguishes `deleted` from `updated` by whether the row survived the transaction.
|
|
1975
|
+
* `created` is not distinguished: nothing records it, and a subscriber re-reads either way.
|
|
1976
|
+
*/
|
|
1977
|
+
#describeChange(entry) {
|
|
1978
|
+
const owner = OWNERSHIP[entry.type];
|
|
1979
|
+
const row = TABLE_BY_ENTITY[entry.type] === undefined
|
|
1980
|
+
? undefined
|
|
1981
|
+
: this.#all(`SELECT * FROM ${TABLE_BY_ENTITY[entry.type] ?? ''} WHERE id = ?`, entry.id)[0];
|
|
1982
|
+
const ownerId = owner === undefined || row === undefined ? null : row[owner.column];
|
|
1983
|
+
return {
|
|
1984
|
+
type: entry.type,
|
|
1985
|
+
id: entry.id,
|
|
1986
|
+
entity_type: entry.type,
|
|
1987
|
+
entity_id: entry.id,
|
|
1988
|
+
owner_id: typeof ownerId === 'string' ? ownerId : null,
|
|
1989
|
+
read_route: readRouteFor(entry.type, entry.id),
|
|
1990
|
+
revision: typeof row?.revision === 'number' ? row.revision : null,
|
|
1991
|
+
change: row === undefined && TABLE_BY_ENTITY[entry.type] !== undefined ? 'deleted' : 'updated',
|
|
1992
|
+
...this.#changeScope(entry.type, row)
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* The 07 §4.2 invalidation scope: `project_id`, `task_id`, aggregate `session_id` and `agent_id`,
|
|
1997
|
+
* derived from Canonical State. Only `project_id` was published, so a subscriber could invalidate
|
|
1998
|
+
* the row that changed and nothing that is computed from it -- a Run reaching `TERMINAL` refreshed
|
|
1999
|
+
* the Run Routes while `SessionSummary.status` and `active_run_count`, both derived from Runs, kept
|
|
2000
|
+
* whatever they last said.
|
|
2001
|
+
*
|
|
2002
|
+
* These fields are invalidation scope, not ownership: they say what to re-read. A Direct Call has no
|
|
2003
|
+
* Agent, Task or Project, and 07 requires omitting the fields there rather than inventing one, which
|
|
2004
|
+
* is why every branch here can come back empty.
|
|
2005
|
+
*/
|
|
2006
|
+
#changeScope(type, row) {
|
|
2007
|
+
if (row === undefined)
|
|
2008
|
+
return {};
|
|
2009
|
+
const agentId = this.#scopeAgentId(type, row);
|
|
2010
|
+
const agent = agentId === null
|
|
2011
|
+
? undefined
|
|
2012
|
+
: this.#all('SELECT task_id, session_id FROM agents WHERE id = ?', agentId)[0];
|
|
2013
|
+
const sessionId = type === 'session' ? optionalScopeId(row.id) : optionalScopeId(agent?.session_id);
|
|
2014
|
+
const taskId = type === 'task'
|
|
2015
|
+
? optionalScopeId(row.id)
|
|
2016
|
+
: type === 'session' ? optionalScopeId(row.task_id) : optionalScopeId(agent?.task_id);
|
|
2017
|
+
const projectId = type === 'project'
|
|
2018
|
+
? optionalScopeId(row.id)
|
|
2019
|
+
: projectIdOf(row) ?? (taskId === undefined ? undefined : optionalScopeId(this.#all('SELECT project_id FROM tasks WHERE id = ?', taskId)[0]?.project_id));
|
|
2020
|
+
return {
|
|
2021
|
+
...(agentId === null ? {} : { agent_id: agentId }),
|
|
2022
|
+
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
2023
|
+
...(taskId === undefined ? {} : { task_id: taskId }),
|
|
2024
|
+
...(projectId === undefined ? {} : { project_id: projectId })
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* The Agent a changed row belongs to. Most rows carry `agent_id` outright; a Step, Partition or
|
|
2029
|
+
* Result needs one hop first, and the walk is at most three rows deep.
|
|
2030
|
+
*/
|
|
2031
|
+
#scopeAgentId(type, row) {
|
|
2032
|
+
if (type === 'agent')
|
|
2033
|
+
return typeof row.id === 'string' ? row.id : null;
|
|
2034
|
+
if (typeof row.agent_id === 'string')
|
|
2035
|
+
return row.agent_id;
|
|
2036
|
+
const parent = SCOPE_PARENT[type];
|
|
2037
|
+
if (parent === undefined)
|
|
2038
|
+
return null;
|
|
2039
|
+
const parentId = row[parent.column];
|
|
2040
|
+
if (typeof parentId !== 'string')
|
|
2041
|
+
return null;
|
|
2042
|
+
const parentRow = this.#all(`SELECT * FROM ${parent.table} WHERE id = ?`, parentId)[0];
|
|
2043
|
+
return parentRow === undefined ? null : this.#scopeAgentId(parent.type, parentRow);
|
|
2044
|
+
}
|
|
2045
|
+
#insertAgent(projectId, agentId, input, timestamp) {
|
|
2046
|
+
const task = this.#require('tasks', input.taskId);
|
|
2047
|
+
owned(task, 'project_id', projectId);
|
|
2048
|
+
const session = this.#require('sessions', input.sessionId);
|
|
2049
|
+
if (String(session.task_id) !== input.taskId)
|
|
2050
|
+
fail('SESSION_NOT_FOUND', 404, 'The Session does not belong to the Task.');
|
|
2051
|
+
this.getWorkspace(projectId, input.workspaceId);
|
|
2052
|
+
let parentCapabilities = null;
|
|
2053
|
+
if (input.parentAgentId !== undefined) {
|
|
2054
|
+
const parent = this.#require('agents', input.parentAgentId);
|
|
2055
|
+
if (String(parent.task_id) !== input.taskId || String(parent.session_id) !== input.sessionId) {
|
|
2056
|
+
fail('AGENT_NOT_FOUND', 404, 'The parent Agent does not belong to the Session.');
|
|
2057
|
+
}
|
|
2058
|
+
parentCapabilities = new Set(this.#all('SELECT capability_id FROM capability_assignments WHERE agent_id = ?', input.parentAgentId).map((row) => String(row.capability_id)));
|
|
2059
|
+
}
|
|
2060
|
+
this.#database.prepare(`INSERT INTO agents(id, task_id, session_id, workspace_id, parent_agent_id, name, state, model, model_reasoning_effort, policy_preset, context_revision, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'IDLE', ?, ?, ?, 0, 0, ?, ?)`)
|
|
2061
|
+
.run(agentId, input.taskId, input.sessionId, input.workspaceId, input.parentAgentId ?? null, input.name, input.model, input.modelReasoningEffort, input.policyPreset, timestamp, timestamp);
|
|
2062
|
+
const assign = this.#database.prepare('INSERT INTO capability_assignments(agent_id, capability_id, policy, assigned_at) VALUES (?, ?, ?, ?)');
|
|
2063
|
+
const load = this.#database.prepare('INSERT INTO capability_loads(agent_id, capability_id, loaded_at) VALUES (?, ?, ?)');
|
|
2064
|
+
for (const [capabilityId, policy] of Object.entries(input.capabilities)) {
|
|
2065
|
+
if (parentCapabilities !== null && !parentCapabilities.has(capabilityId))
|
|
2066
|
+
continue;
|
|
2067
|
+
assign.run(agentId, capabilityId, policy, timestamp);
|
|
2068
|
+
if (input.loadedCapabilities.includes(capabilityId))
|
|
2069
|
+
load.run(agentId, capabilityId, timestamp);
|
|
2070
|
+
}
|
|
2071
|
+
return this.getAgent(projectId, agentId);
|
|
2072
|
+
}
|
|
2073
|
+
#copyContextRange(sourceAgentId, targetAgentId, throughOrdinal, timestamp) {
|
|
2074
|
+
const boundary = this.#database.prepare(`SELECT MAX(ordinal) AS ordinal FROM context_items WHERE agent_id = ? AND kind = 'compaction' AND ordinal <= ?`).get(sourceAgentId, throughOrdinal);
|
|
2075
|
+
const fromOrdinal = boundary.ordinal === null ? 0 : Number(boundary.ordinal);
|
|
2076
|
+
const source = this.#all('SELECT * FROM context_items WHERE agent_id = ? AND ordinal >= ? AND ordinal <= ? AND visible = 1 ORDER BY ordinal', sourceAgentId, fromOrdinal, throughOrdinal);
|
|
2077
|
+
if (source.length === 0 || Number(source.at(-1)?.ordinal) !== throughOrdinal)
|
|
2078
|
+
fail('CONTEXT_GROUP_INVALID', 409, 'The Context fork boundary does not exist.');
|
|
2079
|
+
const lastGroupId = String(source.at(-1)?.turn_group_id);
|
|
2080
|
+
if (this.#count('SELECT COUNT(*) AS count FROM context_items WHERE turn_group_id = ? AND ordinal > ?', lastGroupId, throughOrdinal) !== 0)
|
|
2081
|
+
fail('CONTEXT_GROUP_INVALID', 409, 'Context Fork must end at a complete TurnGroup boundary.');
|
|
2082
|
+
const groupIds = new Map();
|
|
2083
|
+
for (const sourceGroupId of new Set(source.map((row) => String(row.turn_group_id)))) {
|
|
2084
|
+
const group = this.#require('context_turn_groups', sourceGroupId);
|
|
2085
|
+
if (group.state !== 'COMPLETE')
|
|
2086
|
+
fail('CONTEXT_GROUP_INVALID', 409, 'Only complete TurnGroups can be forked.');
|
|
2087
|
+
const targetGroupId = id('turn');
|
|
2088
|
+
groupIds.set(sourceGroupId, targetGroupId);
|
|
2089
|
+
this.#insertTurnGroup(targetGroupId, targetAgentId, String(group.kind), 'COMPLETE', timestamp);
|
|
2090
|
+
}
|
|
2091
|
+
const insert = this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, tool_call_id, tool_name, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`);
|
|
2092
|
+
let ordinal = this.#nextOrdinal('context_items', 'agent_id', targetAgentId);
|
|
2093
|
+
for (const row of source) {
|
|
2094
|
+
const contextId = id('context');
|
|
2095
|
+
let sourceId = String(row.source_id);
|
|
2096
|
+
if (row.kind === 'message') {
|
|
2097
|
+
const messageId = id('message');
|
|
2098
|
+
this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)`)
|
|
2099
|
+
.run(messageId, targetAgentId, sourceAgentId, row.role, row.content, timestamp);
|
|
2100
|
+
sourceId = messageId;
|
|
2101
|
+
}
|
|
2102
|
+
else if (row.kind !== 'result')
|
|
2103
|
+
sourceId = contextId;
|
|
2104
|
+
const targetGroupId = groupIds.get(String(row.turn_group_id));
|
|
2105
|
+
if (targetGroupId === undefined)
|
|
2106
|
+
fail('DATABASE_CORRUPT', 503, 'A Context TurnGroup mapping is missing.');
|
|
2107
|
+
insert.run(contextId, targetAgentId, targetGroupId, row.turn_group_ordinal, row.kind, sourceId, row.role, row.content, row.tool_call_id, row.tool_name, ordinal, row.visible, timestamp, timestamp);
|
|
2108
|
+
ordinal += 1;
|
|
2109
|
+
}
|
|
2110
|
+
this.#touchContext(targetAgentId);
|
|
2111
|
+
return this.#all('SELECT * FROM context_items WHERE agent_id = ? ORDER BY ordinal', targetAgentId).map(doc);
|
|
2112
|
+
}
|
|
2113
|
+
#insertStandaloneContextMessage(agentId, role, content, kind, timestamp) {
|
|
2114
|
+
const messageId = id('message');
|
|
2115
|
+
const turnGroupId = id('turn');
|
|
2116
|
+
const contextId = id('context');
|
|
2117
|
+
this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, NULL, ?, ?, NULL, ?)`)
|
|
2118
|
+
.run(messageId, agentId, role, content, timestamp);
|
|
2119
|
+
this.#insertTurnGroup(turnGroupId, agentId, kind === 'compaction' ? 'compaction' : 'turn', 'COMPLETE', timestamp);
|
|
2120
|
+
this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, 1, 0, 0, ?, ?)`)
|
|
2121
|
+
.run(contextId, agentId, turnGroupId, kind, messageId, role, content, this.#nextOrdinal('context_items', 'agent_id', agentId), timestamp, timestamp);
|
|
2122
|
+
this.#touchContext(agentId);
|
|
2123
|
+
}
|
|
2124
|
+
// Returns what the caller still has to clean up outside the transaction: Artifact files on disk,
|
|
2125
|
+
// and the Calls whose file history the Executor is still holding. Neither can happen in here --
|
|
2126
|
+
// Store transactions do no file or network I/O.
|
|
2127
|
+
#purgeTaskRows(taskIds) {
|
|
2128
|
+
for (const taskId of taskIds) {
|
|
2129
|
+
const references = this.#count(`SELECT COUNT(*) AS count FROM context_items ci JOIN agents target ON target.id = ci.agent_id WHERE ci.kind = 'result' AND target.task_id != ? AND ci.source_id IN (SELECT r.id FROM results r JOIN calls c ON c.id = r.call_id WHERE c.agent_id IN (SELECT id FROM agents WHERE task_id = ?))`, taskId, taskId);
|
|
2130
|
+
if (references > 0)
|
|
2131
|
+
fail('TASK_REFERENCED', 409, 'The archived Task has cross-Task Result references.');
|
|
2132
|
+
}
|
|
2133
|
+
const placeholders = taskIds.map(() => '?').join(',') || "''";
|
|
2134
|
+
const purgedCalls = this.#all(`SELECT id, workspace_id FROM calls WHERE agent_id IN (SELECT id FROM agents WHERE task_id IN (${placeholders}))`, ...taskIds)
|
|
2135
|
+
.map((row) => ({ call_id: String(row.id), workspace_id: String(row.workspace_id) }));
|
|
2136
|
+
const candidateArtifacts = this.#all(`
|
|
2137
|
+
SELECT DISTINCT a.id, a.relative_path FROM artifacts a WHERE a.id IN (
|
|
2138
|
+
SELECT ra.artifact_id FROM result_artifacts ra JOIN results r ON r.id = ra.result_id JOIN calls c ON c.id = r.call_id JOIN agents owner ON owner.id = c.agent_id WHERE owner.task_id IN (${placeholders})
|
|
2139
|
+
)
|
|
2140
|
+
`, ...taskIds);
|
|
2141
|
+
this.#database.prepare(`DELETE FROM calls WHERE agent_id IN (SELECT id FROM agents WHERE task_id IN (${placeholders}))`).run(...taskIds);
|
|
2142
|
+
const removeTask = this.#database.prepare('DELETE FROM tasks WHERE id = ?');
|
|
2143
|
+
for (const taskId of taskIds)
|
|
2144
|
+
removeTask.run(taskId);
|
|
2145
|
+
const orphanedPaths = new Set();
|
|
2146
|
+
for (const artifact of candidateArtifacts) {
|
|
2147
|
+
const artifactId = String(artifact.id);
|
|
2148
|
+
const linked = this.#count('SELECT COUNT(*) AS count FROM result_artifacts WHERE artifact_id = ?', artifactId);
|
|
2149
|
+
const overridden = this.#count(`SELECT COUNT(*) AS count FROM context_overrides WHERE target_kind = 'artifact' AND target_id = ?`, artifactId);
|
|
2150
|
+
if (linked !== 0 || overridden !== 0)
|
|
2151
|
+
continue;
|
|
2152
|
+
const relativePath = String(artifact.relative_path);
|
|
2153
|
+
this.#database.prepare('DELETE FROM artifacts WHERE id = ?').run(artifactId);
|
|
2154
|
+
if (this.#count('SELECT COUNT(*) AS count FROM artifacts WHERE relative_path = ?', relativePath) === 0)
|
|
2155
|
+
orphanedPaths.add(relativePath);
|
|
2156
|
+
}
|
|
2157
|
+
return { orphaned_artifact_paths: [...orphanedPaths], purged_calls: purgedCalls };
|
|
2158
|
+
}
|
|
2159
|
+
#insertContextMessage(agentId, role, content, ids, timestamp, groupKind = 'included') {
|
|
2160
|
+
this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, NULL, ?, ?, NULL, ?)`)
|
|
2161
|
+
.run(ids.messageId, agentId, role, content, timestamp);
|
|
2162
|
+
this.#insertTurnGroup(ids.turnGroupId, agentId, groupKind, 'COMPLETE', timestamp);
|
|
2163
|
+
const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
|
|
2164
|
+
this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'message', ?, ?, ?, ?, 1, 0, 0, ?, ?)`)
|
|
2165
|
+
.run(ids.contextId, agentId, ids.turnGroupId, ids.messageId, role, content, ordinal, timestamp, timestamp);
|
|
2166
|
+
this.#touchContext(agentId);
|
|
2167
|
+
}
|
|
2168
|
+
#insertQueuedMessage(projectId, agentId, content, sourceAgentId, ids, timestamp) {
|
|
2169
|
+
const agent = this.#require('agents', agentId);
|
|
2170
|
+
this.#ownedTask(String(agent.task_id), projectId);
|
|
2171
|
+
if (sourceAgentId !== undefined) {
|
|
2172
|
+
const source = this.#require('agents', sourceAgentId);
|
|
2173
|
+
this.#ownedTask(String(source.task_id), projectId);
|
|
2174
|
+
if (source.task_id !== agent.task_id)
|
|
2175
|
+
fail('AGENT_NOT_FOUND', 404, 'Cross-Agent Messages must remain inside one Task.');
|
|
2176
|
+
}
|
|
2177
|
+
const pendingOrdinal = this.#nextOrdinal('pending_messages', 'agent_id', agentId);
|
|
2178
|
+
const contextOrdinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
|
|
2179
|
+
this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, ?, 'user', ?, NULL, ?)`)
|
|
2180
|
+
.run(ids.messageId, agentId, sourceAgentId ?? null, content, timestamp);
|
|
2181
|
+
this.#database.prepare(`INSERT INTO pending_messages(id, agent_id, message_id, source_agent_id, content, ordinal, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`)
|
|
2182
|
+
.run(ids.pendingId, agentId, ids.messageId, sourceAgentId ?? null, content, pendingOrdinal, timestamp, timestamp);
|
|
2183
|
+
this.#insertTurnGroup(ids.turnGroupId, agentId, 'turn', 'OPEN', timestamp);
|
|
2184
|
+
this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'message', ?, 'user', ?, ?, 1, 0, 0, ?, ?)`)
|
|
2185
|
+
.run(ids.contextId, agentId, ids.turnGroupId, ids.messageId, content, contextOrdinal, timestamp, timestamp);
|
|
2186
|
+
const active = this.#database.prepare(`SELECT * FROM runs WHERE id = ? AND agent_id = ? AND state != 'TERMINAL'`).get(ids.runId, agentId);
|
|
2187
|
+
if (active === undefined) {
|
|
2188
|
+
this.#database.prepare(`INSERT INTO runs(id, agent_id, goal_message_ids_json, state, outcome, error_code, revision, created_at, updated_at) VALUES (?, ?, '[]', 'READY', NULL, NULL, 0, ?, ?)`)
|
|
2189
|
+
.run(ids.runId, agentId, timestamp, timestamp);
|
|
2190
|
+
}
|
|
2191
|
+
this.#touchContext(agentId);
|
|
2192
|
+
return {
|
|
2193
|
+
message: this.getMessage(projectId, ids.messageId),
|
|
2194
|
+
pending: doc(this.#require('pending_messages', ids.pendingId)),
|
|
2195
|
+
run: doc(this.#require('runs', ids.runId))
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
#activeRunId(agentId) {
|
|
2199
|
+
const row = this.#database.prepare(`SELECT id FROM runs WHERE agent_id = ? AND state != 'TERMINAL'`).get(agentId);
|
|
2200
|
+
return row === undefined ? undefined : String(row.id);
|
|
2201
|
+
}
|
|
2202
|
+
#workspace(row) {
|
|
2203
|
+
const paths = this.#all('SELECT path FROM workspace_paths WHERE workspace_id = ? ORDER BY ordinal', String(row.id)).map((entry) => String(entry.path));
|
|
2204
|
+
return { ...doc(row), paths };
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* 02A `AgentSummary`. `is_subagent` and `subagent_list` are derived rather than stored, per the
|
|
2208
|
+
* architecture. `project_id`, `aggregate_session_id` and `model_reference` were absent from the
|
|
2209
|
+
* wire: the column is named `model`, and the other two live one join away, so a client reading
|
|
2210
|
+
* the documented names saw nothing.
|
|
2211
|
+
*/
|
|
2212
|
+
#agent(row) {
|
|
2213
|
+
const agentId = String(row.id);
|
|
2214
|
+
const children = this.#all('SELECT id FROM agents WHERE parent_agent_id = ? ORDER BY created_at, id', agentId).map((entry) => String(entry.id));
|
|
2215
|
+
const owner = this.#all('SELECT project_id FROM tasks WHERE id = ?', String(row.task_id))[0];
|
|
2216
|
+
return {
|
|
2217
|
+
...doc(row),
|
|
2218
|
+
project_id: owner === undefined ? null : String(owner.project_id),
|
|
2219
|
+
aggregate_session_id: typeof row.session_id === 'string' ? row.session_id : null,
|
|
2220
|
+
model_reference: typeof row.model === 'string' ? row.model : null,
|
|
2221
|
+
is_subagent: row.parent_agent_id !== null,
|
|
2222
|
+
subagent_list: children
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
#replaceWorkspacePaths(workspaceId, paths) {
|
|
2226
|
+
this.#database.prepare('DELETE FROM workspace_paths WHERE workspace_id = ?').run(workspaceId);
|
|
2227
|
+
const insert = this.#database.prepare('INSERT INTO workspace_paths(workspace_id, ordinal, path) VALUES (?, ?, ?)');
|
|
2228
|
+
paths.forEach((path, ordinal) => insert.run(workspaceId, ordinal, path));
|
|
2229
|
+
}
|
|
2230
|
+
#insertTurnGroup(idValue, agentId, kind, state, timestamp) {
|
|
2231
|
+
const ordinal = this.#nextOrdinal('context_turn_groups', 'agent_id', agentId);
|
|
2232
|
+
this.#database.prepare(`INSERT INTO context_turn_groups(id, agent_id, ordinal, kind, state, revision, created_at, completed_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`)
|
|
2233
|
+
.run(idValue, agentId, ordinal, kind, state, timestamp, state === 'OPEN' ? null : timestamp);
|
|
2234
|
+
}
|
|
2235
|
+
#touchContext(agentId) {
|
|
2236
|
+
this.#database.prepare('UPDATE agents SET context_revision = context_revision + 1, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
2237
|
+
.run(now(), agentId);
|
|
2238
|
+
}
|
|
2239
|
+
#activeProject(projectId) {
|
|
2240
|
+
if (this.#require('projects', projectId).archived_at !== null)
|
|
2241
|
+
fail('PROJECT_ARCHIVED', 409, 'The Project is archived.');
|
|
2242
|
+
}
|
|
2243
|
+
#ownedTask(taskId, projectId) {
|
|
2244
|
+
owned(this.#require('tasks', taskId), 'project_id', projectId);
|
|
2245
|
+
}
|
|
2246
|
+
#ownedAgent(agentId, projectId) {
|
|
2247
|
+
this.#ownedTask(String(this.#require('agents', agentId).task_id), projectId);
|
|
2248
|
+
}
|
|
2249
|
+
#expect(row, revision) {
|
|
2250
|
+
if (Number(row.revision) !== revision)
|
|
2251
|
+
fail('REVISION_CONFLICT', 409, 'The resource changed before this command was applied.');
|
|
2252
|
+
}
|
|
2253
|
+
#require(table, entityId) {
|
|
2254
|
+
const row = this.#database.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(entityId);
|
|
2255
|
+
if (row === undefined)
|
|
2256
|
+
fail(`${table.replaceAll('_', '').replace(/s$/, '').toUpperCase()}_NOT_FOUND`, 404, 'The requested resource does not exist.');
|
|
2257
|
+
return row;
|
|
2258
|
+
}
|
|
2259
|
+
#one(sql, ...parameters) {
|
|
2260
|
+
const row = this.#database.prepare(sql).get(...parameters);
|
|
2261
|
+
if (row === undefined)
|
|
2262
|
+
fail('DATABASE_CORRUPT', 503, 'An expected database row is missing.');
|
|
2263
|
+
return row;
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* The Workspaces a Task touches, keyed by Task. A Task has no Workspace of its own -- the
|
|
2267
|
+
* binding is `agents.workspace_id` -- and a Task may hold several Root Agents, so the answer is
|
|
2268
|
+
* a set rather than one value. Resolved in a single statement for the whole page: doing it per
|
|
2269
|
+
* Task is the N+1 that the sidebar previously paid for a Workspace grouping key.
|
|
2270
|
+
*/
|
|
2271
|
+
#taskWorkspaceIds(taskIds) {
|
|
2272
|
+
const result = new Map();
|
|
2273
|
+
if (taskIds.length === 0)
|
|
2274
|
+
return result;
|
|
2275
|
+
const rows = this.#all(`SELECT DISTINCT task_id, workspace_id FROM agents WHERE task_id IN (${taskIds.map(() => '?').join(', ')}) ORDER BY workspace_id`, ...taskIds);
|
|
2276
|
+
for (const row of rows) {
|
|
2277
|
+
const key = String(row.task_id);
|
|
2278
|
+
const existing = result.get(key);
|
|
2279
|
+
if (existing === undefined)
|
|
2280
|
+
result.set(key, [String(row.workspace_id)]);
|
|
2281
|
+
else
|
|
2282
|
+
existing.push(String(row.workspace_id));
|
|
2283
|
+
}
|
|
2284
|
+
return result;
|
|
2285
|
+
}
|
|
2286
|
+
#all(sql, ...parameters) {
|
|
2287
|
+
return this.#database.prepare(sql).all(...parameters);
|
|
2288
|
+
}
|
|
2289
|
+
#count(sql, ...parameters) {
|
|
2290
|
+
return Number(this.#one(sql, ...parameters).count);
|
|
2291
|
+
}
|
|
2292
|
+
#nextOrdinal(table, field, ownerId) {
|
|
2293
|
+
return Number(this.#one(`SELECT COALESCE(MAX(ordinal), -1) + 1 AS ordinal FROM ${table} WHERE ${field} = ?`, ownerId).ordinal);
|
|
2294
|
+
}
|
|
2295
|
+
#page(sql, parameters, options, order = 'updated_at DESC, id DESC') {
|
|
2296
|
+
const revision = this.stateRevision();
|
|
2297
|
+
const scope = digest({ sql, parameters: parameters, order });
|
|
2298
|
+
const offset = decodeCursor(options.cursor, revision, scope);
|
|
2299
|
+
const rows = this.#all(`${sql} ORDER BY ${order} LIMIT ? OFFSET ?`, ...parameters, options.limit + 1, offset);
|
|
2300
|
+
const items = rows.slice(0, options.limit).map(doc);
|
|
2301
|
+
return { items, nextCursor: rows.length > options.limit ? encodeCursor(offset + options.limit, revision, scope) : null, collectionRevision: revision };
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* The `CallSummary.legacy_state` projection of the canonical `state`/`wait_reason` pair, per 02A §287.
|
|
2306
|
+
* `YIELDED` is unreachable: nothing stores a yielded flag or timestamp, so a terminal Call reports
|
|
2307
|
+
* `COMPLETED` regardless of outcome, exactly as the physical column allows.
|
|
2308
|
+
*/
|
|
2309
|
+
function legacyCallState(call) {
|
|
2310
|
+
const state = call.state;
|
|
2311
|
+
if (state === 'WAITING')
|
|
2312
|
+
return call.wait_reason === 'user_input' ? 'WAITING_USER' : 'WAITING_REVIEW';
|
|
2313
|
+
if (state === 'RUNNING')
|
|
2314
|
+
return 'EXECUTING';
|
|
2315
|
+
if (state === 'TERMINAL')
|
|
2316
|
+
return 'COMPLETED';
|
|
2317
|
+
return 'QUEUED';
|
|
2318
|
+
}
|
|
2319
|
+
/** The distinct non-null string values of one field across a page of documents. */
|
|
2320
|
+
function ids(documents, field) {
|
|
2321
|
+
return [...new Set(documents.map((entry) => entry[field]).filter((value) => typeof value === 'string'))];
|
|
2322
|
+
}
|
|
2323
|
+
function doc(row) {
|
|
2324
|
+
const result = {};
|
|
2325
|
+
for (const [key, value] of Object.entries(row)) {
|
|
2326
|
+
if (key.endsWith('_json')) {
|
|
2327
|
+
if (value !== null && typeof value !== 'string')
|
|
2328
|
+
fail('DATABASE_CORRUPT', 503, 'A stored JSON field is invalid.');
|
|
2329
|
+
result[key.slice(0, -5)] = parseJson(value);
|
|
2330
|
+
}
|
|
2331
|
+
else if (booleans.has(key))
|
|
2332
|
+
result[key] = Number(value) === 1;
|
|
2333
|
+
else if (value === null || typeof value === 'string' || typeof value === 'number')
|
|
2334
|
+
result[key] = value;
|
|
2335
|
+
else
|
|
2336
|
+
fail('DATABASE_CORRUPT', 503, 'A stored row contains an unsupported value.');
|
|
2337
|
+
}
|
|
2338
|
+
return result;
|
|
2339
|
+
}
|
|
2340
|
+
const booleans = new Set(['is_default', 'visible', 'locked', 'complete', 'loaded', 'truncated', 'outcome_unknown', 'auto_review_passed', 'initial_step_pending', 'existed_before']);
|
|
2341
|
+
/** The table each published entity type lives in, used to resolve its owner and revision. */
|
|
2342
|
+
const TABLE_BY_ENTITY = {
|
|
2343
|
+
project: 'projects', workspace: 'workspaces', task: 'tasks', session: 'sessions', agent: 'agents',
|
|
2344
|
+
pending_message: 'pending_messages', run: 'runs', step: 'steps', model_attempt: 'model_attempts',
|
|
2345
|
+
call: 'calls', message: 'messages', context_item: 'context_items',
|
|
2346
|
+
context_override: 'context_overrides', result: 'results', artifact: 'artifacts'
|
|
2347
|
+
};
|
|
2348
|
+
/** The column naming each entity's owner, which a subscriber uses to refresh the owning view. */
|
|
2349
|
+
const OWNERSHIP = {
|
|
2350
|
+
workspace: { column: 'project_id' }, task: { column: 'project_id' }, session: { column: 'task_id' },
|
|
2351
|
+
agent: { column: 'session_id' }, pending_message: { column: 'agent_id' }, run: { column: 'agent_id' },
|
|
2352
|
+
step: { column: 'run_id' }, model_attempt: { column: 'step_id' }, call: { column: 'step_id' },
|
|
2353
|
+
message: { column: 'agent_id' }, context_item: { column: 'agent_id' },
|
|
2354
|
+
context_override: { column: 'agent_id' }, result: { column: 'call_id' }, artifact: { column: 'project_id' }
|
|
2355
|
+
};
|
|
2356
|
+
/** The Route a subscriber re-reads for a changed entity. */
|
|
2357
|
+
function readRouteFor(type, entityId) {
|
|
2358
|
+
const routes = {
|
|
2359
|
+
project: `/api/v1/projects/${entityId}`,
|
|
2360
|
+
task: `/api/v1/tasks/${entityId}`,
|
|
2361
|
+
session: `/api/v1/sessions/${entityId}`,
|
|
2362
|
+
agent: `/api/v1/agents/${entityId}`,
|
|
2363
|
+
pending_message: `/api/v1/pending-messages/${entityId}`,
|
|
2364
|
+
run: `/api/v1/runs/${entityId}`,
|
|
2365
|
+
step: `/api/v1/steps/${entityId}`,
|
|
2366
|
+
model_attempt: `/api/v1/model-attempts/${entityId}`,
|
|
2367
|
+
call: `/api/v1/calls/${entityId}`,
|
|
2368
|
+
message: `/api/v1/messages/${entityId}`,
|
|
2369
|
+
context_item: `/api/v1/context/${entityId}`,
|
|
2370
|
+
context_override: `/api/v1/context-overrides/${entityId}`,
|
|
2371
|
+
result: `/api/v1/results/${entityId}`,
|
|
2372
|
+
artifact: `/api/v1/artifacts/${entityId}/metadata`
|
|
2373
|
+
};
|
|
2374
|
+
return routes[type] ?? `/api/v1/${type}/${entityId}`;
|
|
2375
|
+
}
|
|
2376
|
+
function projectIdOf(row) {
|
|
2377
|
+
return typeof row?.project_id === 'string' ? row.project_id : undefined;
|
|
2378
|
+
}
|
|
2379
|
+
function optionalScopeId(value) {
|
|
2380
|
+
return typeof value === 'string' ? value : undefined;
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* How many Runs a Session Snapshot carries. Read on every panel poll, so it is a window rather than the
|
|
2384
|
+
* Session's whole history; the paged Run Route is where the rest lives.
|
|
2385
|
+
*/
|
|
2386
|
+
const SNAPSHOT_RUN_LIMIT = 50;
|
|
2387
|
+
function dedupeById(documents) {
|
|
2388
|
+
const byId = new Map();
|
|
2389
|
+
for (const entry of documents) {
|
|
2390
|
+
if (typeof entry.id === 'string' && !byId.has(entry.id))
|
|
2391
|
+
byId.set(entry.id, entry);
|
|
2392
|
+
}
|
|
2393
|
+
return [...byId.values()];
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* 02A `AttentionItem` for the Session Snapshot: the Calls that cannot progress without the user.
|
|
2397
|
+
* Derived rather than stored -- `attention.required` is an event, and a client that reconnects after
|
|
2398
|
+
* one was emitted still has to be able to see what is waiting.
|
|
2399
|
+
*/
|
|
2400
|
+
function attentionItems(calls) {
|
|
2401
|
+
return calls
|
|
2402
|
+
.filter((call) => call.state === 'WAITING')
|
|
2403
|
+
.map((call) => ({
|
|
2404
|
+
code: call.wait_reason === 'user_input' ? 'CALL_WAITING_USER' : 'CALL_WAITING_REVIEW',
|
|
2405
|
+
entity_type: 'call',
|
|
2406
|
+
entity_id: call.call_id ?? null,
|
|
2407
|
+
agent_id: call.agent_id ?? null
|
|
2408
|
+
}));
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* The one row to hop through to reach an Agent, for the entities that do not carry `agent_id`
|
|
2412
|
+
* themselves. Everything else either has the column or has no Agent at all.
|
|
2413
|
+
*/
|
|
2414
|
+
const SCOPE_PARENT = {
|
|
2415
|
+
step: { type: 'run', table: 'runs', column: 'run_id' },
|
|
2416
|
+
partition: { type: 'step', table: 'steps', column: 'step_id' },
|
|
2417
|
+
result: { type: 'call', table: 'calls', column: 'call_id' }
|
|
2418
|
+
};
|
|
2419
|
+
/**
|
|
2420
|
+
* Turns a duplicate Workspace name into the rejection it is. `WORKSPACE_NAME_INVALID` is the closest
|
|
2421
|
+
* documented code -- 07 lists no duplicate-name code -- so the message carries the detail.
|
|
2422
|
+
*/
|
|
2423
|
+
function uniqueName(name, write) {
|
|
2424
|
+
try {
|
|
2425
|
+
write();
|
|
2426
|
+
}
|
|
2427
|
+
catch (error) {
|
|
2428
|
+
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
|
2429
|
+
if (code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
|
2430
|
+
fail('WORKSPACE_NAME_INVALID', 422, `A Workspace named ${name} already exists in this Project.`);
|
|
2431
|
+
}
|
|
2432
|
+
throw error;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
function refs(...entries) {
|
|
2436
|
+
return entries.map(([type, entryId]) => ({ type, id: entryId }));
|
|
2437
|
+
}
|
|
2438
|
+
function messageIdsForWrite(runId = id('run')) {
|
|
2439
|
+
return {
|
|
2440
|
+
messageId: id('message'),
|
|
2441
|
+
pendingId: id('pending'),
|
|
2442
|
+
runId,
|
|
2443
|
+
contextId: id('context'),
|
|
2444
|
+
turnGroupId: id('turn')
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
function messageRefs(ids) {
|
|
2448
|
+
return [
|
|
2449
|
+
['message', ids.messageId],
|
|
2450
|
+
['pending_message', ids.pendingId],
|
|
2451
|
+
['run', ids.runId],
|
|
2452
|
+
['turn_group', ids.turnGroupId],
|
|
2453
|
+
['context_item', ids.contextId]
|
|
2454
|
+
];
|
|
2455
|
+
}
|
|
2456
|
+
function owned(row, field, expected) {
|
|
2457
|
+
if (String(row[field]) !== expected)
|
|
2458
|
+
fail('PROJECT_NOT_FOUND', 404, 'The requested resource does not belong to the Project.');
|
|
2459
|
+
}
|
|
2460
|
+
function encodeCursor(offset, revision, scope) {
|
|
2461
|
+
return Buffer.from(JSON.stringify({ offset, revision, scope })).toString('base64url');
|
|
2462
|
+
}
|
|
2463
|
+
function decodeCursor(cursor, revision, scope) {
|
|
2464
|
+
if (cursor === undefined)
|
|
2465
|
+
return 0;
|
|
2466
|
+
let value;
|
|
2467
|
+
try {
|
|
2468
|
+
value = JSON.parse(Buffer.from(cursor, 'base64url').toString());
|
|
2469
|
+
}
|
|
2470
|
+
catch {
|
|
2471
|
+
fail('CURSOR_INVALID', 400, 'The cursor is invalid.');
|
|
2472
|
+
}
|
|
2473
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
2474
|
+
fail('CURSOR_INVALID', 400, 'The cursor is invalid.');
|
|
2475
|
+
const parsed = value;
|
|
2476
|
+
if (!Number.isSafeInteger(parsed.offset) || typeof parsed.offset !== 'number' || parsed.offset < 0 || typeof parsed.revision !== 'number' || typeof parsed.scope !== 'string')
|
|
2477
|
+
fail('CURSOR_INVALID', 400, 'The cursor is invalid.');
|
|
2478
|
+
if (parsed.scope !== scope)
|
|
2479
|
+
fail('CURSOR_INVALID', 400, 'The cursor belongs to another collection.');
|
|
2480
|
+
if (parsed.revision !== revision)
|
|
2481
|
+
fail('PAGE_REVISION_CONFLICT', 409, 'The paged collection changed.');
|
|
2482
|
+
return parsed.offset;
|
|
2483
|
+
}
|
|
2484
|
+
function validateAnswer(prompt, answer) {
|
|
2485
|
+
if (answer === null)
|
|
2486
|
+
fail('USER_INPUT_INVALID', 422, 'A user answer is required.');
|
|
2487
|
+
if (prompt?.kind === 'policy_approval') {
|
|
2488
|
+
if (policyAnswer(answer) === null)
|
|
2489
|
+
fail('USER_INPUT_INVALID', 422, 'The policy answer must approve or reject the Call.');
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
if (prompt?.kind === 'mcp_input_required') {
|
|
2493
|
+
if (jsonRecord(answer) === null)
|
|
2494
|
+
fail('USER_INPUT_INVALID', 422, 'MCP input responses must be an object.');
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
if (prompt?.kind !== 'question')
|
|
2498
|
+
return;
|
|
2499
|
+
const answers = Array.isArray(answer) ? answer : [answer];
|
|
2500
|
+
if (answers.length === 0 || (Array.isArray(answer) && prompt.multi_select !== true))
|
|
2501
|
+
fail('USER_INPUT_INVALID', 422, 'The question accepts a non-empty answer of the configured shape.');
|
|
2502
|
+
const options = [];
|
|
2503
|
+
if (Array.isArray(prompt.options)) {
|
|
2504
|
+
for (const entry of prompt.options) {
|
|
2505
|
+
const value = jsonRecord(entry)?.value;
|
|
2506
|
+
if (typeof value === 'string')
|
|
2507
|
+
options.push(value);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
if (prompt.allow_freeform === false && answers.some((entry) => typeof entry !== 'string' || !options.includes(entry))) {
|
|
2511
|
+
fail('USER_INPUT_INVALID', 422, 'The answer is not one of the configured options.');
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
function policyAnswer(value) {
|
|
2515
|
+
const selected = typeof value === 'string' ? value : jsonRecord(value)?.decision;
|
|
2516
|
+
return selected === 'approve' || selected === 'reject' ? selected : null;
|
|
2517
|
+
}
|
|
2518
|
+
function jsonRecord(value) {
|
|
2519
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
2520
|
+
return null;
|
|
2521
|
+
return value;
|
|
2522
|
+
}
|
|
2523
|
+
function stringField(value) {
|
|
2524
|
+
if (typeof value !== 'string')
|
|
2525
|
+
fail('DATABASE_CORRUPT', 503, 'A stored string field is invalid.');
|
|
2526
|
+
return value;
|
|
2527
|
+
}
|
|
2528
|
+
function strings(value) {
|
|
2529
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))
|
|
2530
|
+
fail('DATABASE_CORRUPT', 503, 'A stored string list is invalid.');
|
|
2531
|
+
return value;
|
|
2532
|
+
}
|
|
2533
|
+
function messageIngressResponse(value) {
|
|
2534
|
+
return {
|
|
2535
|
+
message: documentField(value.message),
|
|
2536
|
+
pending: documentField(value.pending),
|
|
2537
|
+
run: documentField(value.run)
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
function documentField(value) {
|
|
2541
|
+
if (!isJsonObject(value))
|
|
2542
|
+
fail('DATABASE_CORRUPT', 503, 'An ingress response field is invalid.');
|
|
2543
|
+
return value;
|
|
2544
|
+
}
|
|
2545
|
+
function isJsonObject(value) {
|
|
2546
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
2547
|
+
}
|
|
2548
|
+
function rawJson(row) {
|
|
2549
|
+
return Object.fromEntries(Object.entries(row).map(([key, value]) => {
|
|
2550
|
+
if (value === null || typeof value === 'string' || typeof value === 'number')
|
|
2551
|
+
return [key, value];
|
|
2552
|
+
fail('DATABASE_CORRUPT', 503, 'A database row cannot be exported.');
|
|
2553
|
+
}));
|
|
2554
|
+
}
|