@springbrand/agent-runtime 0.1.3-alpha.7 → 0.2.0-alpha.13
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/package.json +4 -3
- package/src/adapter/cloudflare/index.ts +1 -0
- package/src/adapter/cloudflare/sandbox/adapter.ts +4 -0
- package/src/adapter/cloudflare/subagent/definition.ts +60 -5
- package/src/adapter/cloudflare/universal-agent/hooks.ts +23 -1
- package/src/adapter/cloudflare/universal-agent/preparation.ts +34 -0
- package/src/adapter/cloudflare/universal-agent/tools.ts +7 -7
- package/src/adapter/cloudflare/workspace/git-fs.ts +178 -0
- package/src/adapter/cloudflare/workspace/version-control.ts +374 -0
- package/src/db/index.ts +4 -0
- package/src/db/runtime-event-outbox.repo.ts +34 -1
- package/src/db/schema.ts +42 -0
- package/src/db/submission-admission.repo.ts +127 -0
- package/src/db/submission.repo.ts +54 -3
- package/src/index.ts +3 -0
- package/src/kernel/bindings.ts +52 -0
- package/src/kernel/durable-lifecycle.ts +100 -0
- package/src/kernel/public-contracts.ts +11 -0
- package/src/kernel/receipts.ts +1 -0
- package/src/kernel/subagent-runtime.ts +137 -0
- package/src/kernel/submission-authority.ts +114 -0
- package/src/kernel/submission-lifecycle.ts +17 -5
- package/src/lib/prompt.ts +3 -0
- package/src/pi/runtime-adapter/assembly.ts +2 -1
- package/src/pi/runtime-adapter/execution.ts +8 -1
- package/src/pi/runtime-adapter/index.ts +33 -0
- package/src/pi/runtime-adapter/models.ts +9 -0
- package/src/pi/tool/core-host.ts +5 -1
- package/src/pi/tool/core.ts +1 -10
- package/src/pi/tool/index.ts +1 -0
- package/src/pi/tool/mcp.ts +2 -2
- package/src/pi/tool/schedule.ts +30 -18
- package/src/pi/tool/subagent.ts +142 -16
- package/src/pi/tool/workspace-revision.ts +64 -0
- package/src/runtime-agent-context.ts +1 -0
- package/src/runtime-assembler.ts +16 -1
- package/src/runtime-definition.ts +12 -0
- package/src/runtime.ts +427 -46
- package/src/tool-registry.ts +2 -0
- package/src/workspace-versioning.ts +46 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import * as git from "isomorphic-git";
|
|
2
|
+
import type { WorkspacePort } from "../../../kernel/bindings";
|
|
3
|
+
import {
|
|
4
|
+
WorkspaceRevisionNotFoundError,
|
|
5
|
+
type WorkspaceRevision,
|
|
6
|
+
type WorkspaceRevisionManifest,
|
|
7
|
+
type WorkspaceVersionPort,
|
|
8
|
+
} from "../../../workspace-versioning";
|
|
9
|
+
import { createWorkspaceGitFs } from "./git-fs";
|
|
10
|
+
|
|
11
|
+
const PAGE_SIZE = 256;
|
|
12
|
+
const MAX_LIST_LIMIT = 100;
|
|
13
|
+
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
|
|
14
|
+
const AUTHOR = Object.freeze({
|
|
15
|
+
name: "Workspace",
|
|
16
|
+
email: "workspace@springbrand.local",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
type VersionControlOptions = {
|
|
20
|
+
workspace: WorkspacePort;
|
|
21
|
+
dir: string;
|
|
22
|
+
gitdir: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type TreeFile = {
|
|
26
|
+
path: string;
|
|
27
|
+
oid: string;
|
|
28
|
+
bytes: Uint8Array;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function coordinate(value: string): string {
|
|
32
|
+
if (!value.startsWith("/") || value.includes("\\")) {
|
|
33
|
+
throw new Error("Workspace revision coordinates must be absolute POSIX paths");
|
|
34
|
+
}
|
|
35
|
+
const parts = value.split("/").filter(Boolean);
|
|
36
|
+
if (parts.some((part) => part === "." || part === "..")) {
|
|
37
|
+
throw new Error("Workspace revision coordinates cannot traverse");
|
|
38
|
+
}
|
|
39
|
+
return `/${parts.join("/")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function relativePath(value: string): string {
|
|
43
|
+
if (!value || value.startsWith("/") || value.includes("\\")) {
|
|
44
|
+
throw new WorkspaceRevisionNotFoundError();
|
|
45
|
+
}
|
|
46
|
+
const parts = value.split("/");
|
|
47
|
+
if (parts.some((part) => !part || part === "." || part === ".." || part === ".git")) {
|
|
48
|
+
throw new WorkspaceRevisionNotFoundError();
|
|
49
|
+
}
|
|
50
|
+
return parts.join("/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pathIn(root: string, relative: string): string {
|
|
54
|
+
return `${root}/${relative}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isMissing(error: unknown) {
|
|
58
|
+
return error instanceof Error && (
|
|
59
|
+
"code" in error && String((error as { code: unknown }).code).includes("NotFound") ||
|
|
60
|
+
error.message.includes("Could not find") ||
|
|
61
|
+
error.message.includes("ENOENT")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A linear Git history for one Host-selected Workspace subtree. */
|
|
66
|
+
export class WorkspaceVersionControl implements WorkspaceVersionPort {
|
|
67
|
+
private readonly workspace: WorkspacePort;
|
|
68
|
+
private readonly dir: string;
|
|
69
|
+
private readonly gitdir: string;
|
|
70
|
+
private readonly fs: git.FsClient;
|
|
71
|
+
|
|
72
|
+
constructor(options: VersionControlOptions) {
|
|
73
|
+
this.workspace = options.workspace;
|
|
74
|
+
this.dir = coordinate(options.dir);
|
|
75
|
+
this.gitdir = coordinate(options.gitdir);
|
|
76
|
+
if (
|
|
77
|
+
this.dir === this.gitdir ||
|
|
78
|
+
this.dir.startsWith(`${this.gitdir}/`) ||
|
|
79
|
+
this.gitdir.startsWith(`${this.dir}/`)
|
|
80
|
+
) throw new Error("Workspace revision gitdir must be separate from its worktree");
|
|
81
|
+
this.fs = createWorkspaceGitFs(this.workspace);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async current(): Promise<WorkspaceRevision | null> {
|
|
85
|
+
await this.ensure();
|
|
86
|
+
try {
|
|
87
|
+
const entries = await git.log({
|
|
88
|
+
fs: this.fs,
|
|
89
|
+
dir: this.dir,
|
|
90
|
+
gitdir: this.gitdir,
|
|
91
|
+
depth: 1,
|
|
92
|
+
});
|
|
93
|
+
return entries[0] ? this.revision(entries[0]) : null;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (isMissing(error)) return null;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async checkpoint(input: { reason: string }) {
|
|
101
|
+
await this.ensure();
|
|
102
|
+
const reason = input.reason.trim();
|
|
103
|
+
if (!reason) throw new Error("Workspace revision reason is required");
|
|
104
|
+
const currentFiles = await this.worktreeFiles();
|
|
105
|
+
const before = await this.current();
|
|
106
|
+
if (currentFiles.size === 0 && !before) {
|
|
107
|
+
return { revision: null, treeHash: null, changed: false };
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
before &&
|
|
111
|
+
sameFiles(currentFiles, (await this.readRevision(before.revision)).files)
|
|
112
|
+
) {
|
|
113
|
+
return {
|
|
114
|
+
revision: before.revision,
|
|
115
|
+
treeHash: before.treeHash,
|
|
116
|
+
changed: false,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await this.stageWorktree(currentFiles);
|
|
121
|
+
const revision = await git.commit({
|
|
122
|
+
fs: this.fs,
|
|
123
|
+
dir: this.dir,
|
|
124
|
+
gitdir: this.gitdir,
|
|
125
|
+
message: reason,
|
|
126
|
+
author: AUTHOR,
|
|
127
|
+
committer: AUTHOR,
|
|
128
|
+
});
|
|
129
|
+
const treeHash = (await git.readTree({
|
|
130
|
+
fs: this.fs,
|
|
131
|
+
dir: this.dir,
|
|
132
|
+
gitdir: this.gitdir,
|
|
133
|
+
oid: revision,
|
|
134
|
+
})).oid;
|
|
135
|
+
return { revision, treeHash, changed: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async list(input?: { limit?: number }) {
|
|
139
|
+
await this.ensure();
|
|
140
|
+
const limit = Math.min(Math.max(input?.limit ?? 20, 1), MAX_LIST_LIMIT);
|
|
141
|
+
try {
|
|
142
|
+
return Promise.all((await git.log({
|
|
143
|
+
fs: this.fs,
|
|
144
|
+
dir: this.dir,
|
|
145
|
+
gitdir: this.gitdir,
|
|
146
|
+
depth: limit,
|
|
147
|
+
})).map((entry) => this.revision(entry)));
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (isMissing(error)) return [];
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async manifest(revision: string): Promise<WorkspaceRevisionManifest> {
|
|
155
|
+
const { treeHash, files } = await this.readRevision(revision);
|
|
156
|
+
return {
|
|
157
|
+
revision,
|
|
158
|
+
treeHash,
|
|
159
|
+
files: files.map(({ path, bytes }) => ({ path, size: bytes.byteLength })),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async readFile(revision: string, path: string): Promise<Uint8Array> {
|
|
164
|
+
const normalized = relativePath(path);
|
|
165
|
+
const { files } = await this.readRevision(revision);
|
|
166
|
+
const file = files.find((candidate) => candidate.path === normalized);
|
|
167
|
+
if (!file) throw new WorkspaceRevisionNotFoundError();
|
|
168
|
+
return file.bytes.slice();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async restore(input: { revision: string; reason: string }) {
|
|
172
|
+
const target = await this.readRevision(input.revision);
|
|
173
|
+
const preserved = await this.checkpoint({ reason: "Before restore" });
|
|
174
|
+
if (preserved.treeHash === target.treeHash) {
|
|
175
|
+
return {
|
|
176
|
+
revision: preserved.revision!,
|
|
177
|
+
treeHash: preserved.treeHash!,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const before = preserved.revision
|
|
181
|
+
? await this.readRevision(preserved.revision)
|
|
182
|
+
: { treeHash: "", files: [] };
|
|
183
|
+
try {
|
|
184
|
+
await this.replaceWorktree(target.files);
|
|
185
|
+
const restored = await this.checkpoint({ reason: input.reason });
|
|
186
|
+
return { revision: restored.revision!, treeHash: restored.treeHash! };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
try {
|
|
189
|
+
await this.replaceWorktree(before.files);
|
|
190
|
+
await this.stageWorktree();
|
|
191
|
+
} catch (compensationError) {
|
|
192
|
+
throw new AggregateError(
|
|
193
|
+
[error, compensationError],
|
|
194
|
+
"workspace_revision_restore_compensation_failed",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async ensure() {
|
|
202
|
+
await this.workspace.mkdir(this.dir, { recursive: true });
|
|
203
|
+
await this.workspace.mkdir(this.gitdir, { recursive: true });
|
|
204
|
+
await git.init({
|
|
205
|
+
fs: this.fs,
|
|
206
|
+
dir: this.dir,
|
|
207
|
+
gitdir: this.gitdir,
|
|
208
|
+
defaultBranch: "main",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async revision(entry: Awaited<ReturnType<typeof git.log>>[number]) {
|
|
213
|
+
const treeHash = (await git.readTree({
|
|
214
|
+
fs: this.fs,
|
|
215
|
+
dir: this.dir,
|
|
216
|
+
gitdir: this.gitdir,
|
|
217
|
+
oid: entry.oid,
|
|
218
|
+
})).oid;
|
|
219
|
+
return {
|
|
220
|
+
revision: entry.oid,
|
|
221
|
+
treeHash,
|
|
222
|
+
reason: entry.commit.message.trim(),
|
|
223
|
+
createdAt: entry.commit.author.timestamp * 1_000,
|
|
224
|
+
} satisfies WorkspaceRevision;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async readRevision(revision: string): Promise<{
|
|
228
|
+
treeHash: string;
|
|
229
|
+
files: TreeFile[];
|
|
230
|
+
}> {
|
|
231
|
+
await this.ensure();
|
|
232
|
+
if (!REVISION_PATTERN.test(revision)) throw new WorkspaceRevisionNotFoundError();
|
|
233
|
+
try {
|
|
234
|
+
await git.readCommit({
|
|
235
|
+
fs: this.fs,
|
|
236
|
+
dir: this.dir,
|
|
237
|
+
gitdir: this.gitdir,
|
|
238
|
+
oid: revision,
|
|
239
|
+
});
|
|
240
|
+
const root = await git.readTree({
|
|
241
|
+
fs: this.fs,
|
|
242
|
+
dir: this.dir,
|
|
243
|
+
gitdir: this.gitdir,
|
|
244
|
+
oid: revision,
|
|
245
|
+
});
|
|
246
|
+
const files: TreeFile[] = [];
|
|
247
|
+
await this.readTreeFiles(root.oid, "", files);
|
|
248
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
249
|
+
return { treeHash: root.oid, files };
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error instanceof WorkspaceRevisionNotFoundError || isMissing(error)) {
|
|
252
|
+
throw new WorkspaceRevisionNotFoundError();
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async readTreeFiles(oid: string, prefix: string, files: TreeFile[]) {
|
|
259
|
+
const { tree } = await git.readTree({
|
|
260
|
+
fs: this.fs,
|
|
261
|
+
dir: this.dir,
|
|
262
|
+
gitdir: this.gitdir,
|
|
263
|
+
oid,
|
|
264
|
+
});
|
|
265
|
+
for (const entry of tree) {
|
|
266
|
+
const path = prefix ? `${prefix}/${entry.path}` : entry.path;
|
|
267
|
+
relativePath(path);
|
|
268
|
+
if (entry.type === "tree") {
|
|
269
|
+
await this.readTreeFiles(entry.oid, path, files);
|
|
270
|
+
} else if (entry.type === "blob" && entry.mode !== "120000") {
|
|
271
|
+
files.push({
|
|
272
|
+
path,
|
|
273
|
+
oid: entry.oid,
|
|
274
|
+
bytes: (await git.readBlob({
|
|
275
|
+
fs: this.fs,
|
|
276
|
+
dir: this.dir,
|
|
277
|
+
gitdir: this.gitdir,
|
|
278
|
+
oid: entry.oid,
|
|
279
|
+
})).blob,
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
throw new WorkspaceRevisionNotFoundError();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async worktreeFiles() {
|
|
288
|
+
const files = new Map<string, Uint8Array>();
|
|
289
|
+
const pending = [this.dir];
|
|
290
|
+
while (pending.length > 0) {
|
|
291
|
+
const directory = pending.pop()!;
|
|
292
|
+
for (const entry of await this.readDir(directory)) {
|
|
293
|
+
const path = entry.path.slice(this.dir.length + 1);
|
|
294
|
+
relativePath(path);
|
|
295
|
+
if (entry.type === "directory") pending.push(entry.path);
|
|
296
|
+
else if (entry.type === "file") {
|
|
297
|
+
const bytes = await this.workspace.readFileBytes(entry.path);
|
|
298
|
+
if (!bytes) throw new Error("Workspace file disappeared during checkpoint");
|
|
299
|
+
files.set(path, bytes);
|
|
300
|
+
} else {
|
|
301
|
+
throw new Error("Workspace revisions do not support symbolic links");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return files;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async readDir(path: string) {
|
|
309
|
+
const entries = [];
|
|
310
|
+
let offset = 0;
|
|
311
|
+
while (true) {
|
|
312
|
+
const page = await this.workspace.readDir(path, {
|
|
313
|
+
limit: PAGE_SIZE,
|
|
314
|
+
offset,
|
|
315
|
+
});
|
|
316
|
+
if (page.length === 0) return entries;
|
|
317
|
+
entries.push(...page);
|
|
318
|
+
offset += page.length;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private async replaceWorktree(files: readonly TreeFile[]) {
|
|
323
|
+
const target = new Set(files.map(({ path }) => path));
|
|
324
|
+
const current = await this.worktreeFiles();
|
|
325
|
+
for (const path of current.keys()) {
|
|
326
|
+
if (!target.has(path)) {
|
|
327
|
+
await this.workspace.rm(pathIn(this.dir, path), { force: true });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const file of files) {
|
|
331
|
+
await this.workspace.writeFileBytes(pathIn(this.dir, file.path), file.bytes);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async stageWorktree(files?: ReadonlyMap<string, Uint8Array>) {
|
|
336
|
+
files ??= await this.worktreeFiles();
|
|
337
|
+
const tracked = new Set(await git.listFiles({
|
|
338
|
+
fs: this.fs,
|
|
339
|
+
dir: this.dir,
|
|
340
|
+
gitdir: this.gitdir,
|
|
341
|
+
}));
|
|
342
|
+
for (const path of files.keys()) {
|
|
343
|
+
await git.add({
|
|
344
|
+
fs: this.fs,
|
|
345
|
+
dir: this.dir,
|
|
346
|
+
gitdir: this.gitdir,
|
|
347
|
+
filepath: path,
|
|
348
|
+
force: true,
|
|
349
|
+
});
|
|
350
|
+
tracked.delete(path);
|
|
351
|
+
}
|
|
352
|
+
for (const path of tracked) {
|
|
353
|
+
await git.remove({
|
|
354
|
+
fs: this.fs,
|
|
355
|
+
dir: this.dir,
|
|
356
|
+
gitdir: this.gitdir,
|
|
357
|
+
filepath: path,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function sameFiles(
|
|
365
|
+
current: ReadonlyMap<string, Uint8Array>,
|
|
366
|
+
revision: readonly TreeFile[],
|
|
367
|
+
) {
|
|
368
|
+
if (current.size !== revision.length) return false;
|
|
369
|
+
return revision.every(({ path, bytes }) => {
|
|
370
|
+
const candidate = current.get(path);
|
|
371
|
+
return candidate?.byteLength === bytes.byteLength &&
|
|
372
|
+
candidate.every((byte, index) => byte === bytes[index]);
|
|
373
|
+
});
|
|
374
|
+
}
|
package/src/db/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { MessageUiRepository } from "./message-ui.repo";
|
|
|
11
11
|
import { SteerRepository } from "./steer.repo";
|
|
12
12
|
import { RuntimeEventOutboxRepository } from "./runtime-event-outbox.repo";
|
|
13
13
|
import { AgentToolRepository } from "./agent-tool.repo";
|
|
14
|
+
import { SubmissionAdmissionRepository } from "./submission-admission.repo";
|
|
14
15
|
|
|
15
16
|
// 数据库术语以这里为准。
|
|
16
17
|
// Submission 是一次可持久化的用户提交,从排队到终态都用同一个 submissionId 跟踪。
|
|
@@ -37,6 +38,7 @@ export * from "./message-ui.repo";
|
|
|
37
38
|
export * from "./steer.repo";
|
|
38
39
|
export * from "./runtime-event-outbox.repo";
|
|
39
40
|
export * from "./agent-tool.repo";
|
|
41
|
+
export * from "./submission-admission.repo";
|
|
40
42
|
|
|
41
43
|
export class RuntimeDatabase {
|
|
42
44
|
readonly submissions: SubmissionRepository;
|
|
@@ -49,6 +51,7 @@ export class RuntimeDatabase {
|
|
|
49
51
|
readonly steers: SteerRepository;
|
|
50
52
|
readonly runtimeEvents: RuntimeEventOutboxRepository;
|
|
51
53
|
readonly agentTools: AgentToolRepository;
|
|
54
|
+
readonly submissionAdmissions: SubmissionAdmissionRepository;
|
|
52
55
|
|
|
53
56
|
// 给各个 Repository 分配同一个 Agent SQLite 入口和事务入口。
|
|
54
57
|
// AgentRuntimeKernel 构造时只创建一次,业务代码随后通过对应属性访问仓储。
|
|
@@ -67,6 +70,7 @@ export class RuntimeDatabase {
|
|
|
67
70
|
this.steers = new SteerRepository(sql);
|
|
68
71
|
this.runtimeEvents = new RuntimeEventOutboxRepository(sql);
|
|
69
72
|
this.agentTools = new AgentToolRepository(sql);
|
|
73
|
+
this.submissionAdmissions = new SubmissionAdmissionRepository(sql);
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/**
|
|
@@ -55,7 +55,7 @@ export class RuntimeEventOutboxRepository {
|
|
|
55
55
|
SELECT event_id, body, created_at, delivered_at
|
|
56
56
|
FROM pi_turn_event_outbox
|
|
57
57
|
WHERE delivered_at IS NULL
|
|
58
|
-
ORDER BY created_at,
|
|
58
|
+
ORDER BY created_at, rowid
|
|
59
59
|
`.map(mapRow);
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -70,6 +70,39 @@ export class RuntimeEventOutboxRepository {
|
|
|
70
70
|
);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
hasNonZeroModelUsage(submissionId: string): boolean {
|
|
74
|
+
return Boolean(
|
|
75
|
+
this.sql<{ found: number }>`
|
|
76
|
+
SELECT 1 AS found
|
|
77
|
+
FROM pi_turn_event_outbox
|
|
78
|
+
WHERE json_extract(body, '$.type') = 'model-usage'
|
|
79
|
+
AND json_extract(body, '$.event.submissionId') = ${submissionId}
|
|
80
|
+
AND (
|
|
81
|
+
COALESCE(json_extract(body, '$.event.usage.totalTokens'), 0) > 0
|
|
82
|
+
OR COALESCE(json_extract(body, '$.event.usage.cost.total'), 0) > 0
|
|
83
|
+
)
|
|
84
|
+
LIMIT 1
|
|
85
|
+
`[0],
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
has(eventId: string): boolean {
|
|
90
|
+
return this.find(eventId) !== null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
hasSubagentUsage(submissionId: string, subagentRunId: string): boolean {
|
|
94
|
+
return Boolean(
|
|
95
|
+
this.sql<{ found: number }>`
|
|
96
|
+
SELECT 1 AS found
|
|
97
|
+
FROM pi_turn_event_outbox
|
|
98
|
+
WHERE json_extract(body, '$.type') = 'subagent-usage'
|
|
99
|
+
AND json_extract(body, '$.event.submissionId') = ${submissionId}
|
|
100
|
+
AND json_extract(body, '$.event.subagentRunId') = ${subagentRunId}
|
|
101
|
+
LIMIT 1
|
|
102
|
+
`[0],
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
73
106
|
markDelivered(eventId: string, deliveredAt: number): void {
|
|
74
107
|
this.sql`
|
|
75
108
|
UPDATE pi_turn_event_outbox
|
package/src/db/schema.ts
CHANGED
|
@@ -31,6 +31,23 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
31
31
|
sql<{ name: string }>`PRAGMA table_info(pi_submissions)`
|
|
32
32
|
.map((column) => column.name),
|
|
33
33
|
);
|
|
34
|
+
if (!submissionColumns.has("run_id")) {
|
|
35
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN run_id TEXT`;
|
|
36
|
+
}
|
|
37
|
+
if (!submissionColumns.has("account_id")) {
|
|
38
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN account_id TEXT`;
|
|
39
|
+
}
|
|
40
|
+
if (!submissionColumns.has("rate_version")) {
|
|
41
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN rate_version INTEGER`;
|
|
42
|
+
}
|
|
43
|
+
if (!submissionColumns.has("slot_identity")) {
|
|
44
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN slot_identity TEXT`;
|
|
45
|
+
}
|
|
46
|
+
if (!submissionColumns.has("admission_retryable")) {
|
|
47
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN admission_retryable INTEGER`;
|
|
48
|
+
}
|
|
49
|
+
sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_run_id
|
|
50
|
+
ON pi_submissions(run_id) WHERE run_id IS NOT NULL`;
|
|
34
51
|
if (!submissionColumns.has("queued_input_json")) {
|
|
35
52
|
sql`ALTER TABLE pi_submissions ADD COLUMN queued_input_json TEXT`;
|
|
36
53
|
}
|
|
@@ -77,6 +94,31 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
77
94
|
created_at INTEGER NOT NULL,
|
|
78
95
|
delivered_at INTEGER
|
|
79
96
|
)`;
|
|
97
|
+
sql`CREATE TABLE IF NOT EXISTS pi_submission_admissions (
|
|
98
|
+
request_id TEXT PRIMARY KEY,
|
|
99
|
+
idempotency_key TEXT UNIQUE,
|
|
100
|
+
run_id TEXT NOT NULL,
|
|
101
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'rejected')),
|
|
102
|
+
account_id TEXT,
|
|
103
|
+
rate_version INTEGER,
|
|
104
|
+
slot_identity TEXT,
|
|
105
|
+
code TEXT,
|
|
106
|
+
retryable INTEGER,
|
|
107
|
+
attempted_at INTEGER NOT NULL DEFAULT 0
|
|
108
|
+
)`;
|
|
109
|
+
const admissionColumns = new Set(
|
|
110
|
+
sql<{ name: string }>`PRAGMA table_info(pi_submission_admissions)`
|
|
111
|
+
.map((column) => column.name),
|
|
112
|
+
);
|
|
113
|
+
if (!admissionColumns.has("retryable")) {
|
|
114
|
+
sql`ALTER TABLE pi_submission_admissions ADD COLUMN retryable INTEGER`;
|
|
115
|
+
}
|
|
116
|
+
// 早于本列的 pending 行没有可信的尝试时间,默认 0 让它们不再占用在途名额,
|
|
117
|
+
// 但仍作为身份记录保留同一个 runId。
|
|
118
|
+
if (!admissionColumns.has("attempted_at")) {
|
|
119
|
+
sql`ALTER TABLE pi_submission_admissions
|
|
120
|
+
ADD COLUMN attempted_at INTEGER NOT NULL DEFAULT 0`;
|
|
121
|
+
}
|
|
80
122
|
sql`CREATE TABLE IF NOT EXISTS pi_approvals (
|
|
81
123
|
execution_id TEXT PRIMARY KEY,
|
|
82
124
|
submission_id TEXT NOT NULL,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { SqlTaggedTemplate } from "agents/chat";
|
|
2
|
+
|
|
3
|
+
export type StoredSubmissionAdmission = {
|
|
4
|
+
requestId: string;
|
|
5
|
+
idempotencyKey: string | null;
|
|
6
|
+
runId: string;
|
|
7
|
+
status: "pending" | "accepted" | "rejected";
|
|
8
|
+
accountId: string | null;
|
|
9
|
+
rateVersion: number | null;
|
|
10
|
+
slotIdentity: string | null;
|
|
11
|
+
code: string | null;
|
|
12
|
+
retryable: boolean | null;
|
|
13
|
+
attemptedAt: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Row = {
|
|
17
|
+
request_id: string;
|
|
18
|
+
idempotency_key: string | null;
|
|
19
|
+
run_id: string;
|
|
20
|
+
status: StoredSubmissionAdmission["status"];
|
|
21
|
+
account_id: string | null;
|
|
22
|
+
rate_version: number | null;
|
|
23
|
+
slot_identity: string | null;
|
|
24
|
+
code: string | null;
|
|
25
|
+
retryable: number | null;
|
|
26
|
+
attempted_at: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const mapRow = (row: Row): StoredSubmissionAdmission => ({
|
|
30
|
+
requestId: row.request_id,
|
|
31
|
+
idempotencyKey: row.idempotency_key,
|
|
32
|
+
runId: row.run_id,
|
|
33
|
+
status: row.status,
|
|
34
|
+
accountId: row.account_id,
|
|
35
|
+
rateVersion: row.rate_version,
|
|
36
|
+
slotIdentity: row.slot_identity,
|
|
37
|
+
code: row.code,
|
|
38
|
+
retryable: row.retryable === null ? null : row.retryable === 1,
|
|
39
|
+
attemptedAt: row.attempted_at,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export class SubmissionAdmissionRepository {
|
|
43
|
+
constructor(private readonly sql: SqlTaggedTemplate) {}
|
|
44
|
+
|
|
45
|
+
find(requestId: string, idempotencyKey?: string): StoredSubmissionAdmission | null {
|
|
46
|
+
const byRequest = this.sql<Row>`SELECT request_id, idempotency_key, run_id, status,
|
|
47
|
+
account_id, rate_version, slot_identity, code, retryable, attempted_at
|
|
48
|
+
FROM pi_submission_admissions WHERE request_id = ${requestId}`[0];
|
|
49
|
+
if (!idempotencyKey) return byRequest ? mapRow(byRequest) : null;
|
|
50
|
+
const byKey = this.sql<Row>`SELECT request_id, idempotency_key, run_id, status,
|
|
51
|
+
account_id, rate_version, slot_identity, code, retryable, attempted_at
|
|
52
|
+
FROM pi_submission_admissions WHERE idempotency_key = ${idempotencyKey}`[0];
|
|
53
|
+
if (byRequest && byKey && byRequest.run_id !== byKey.run_id) {
|
|
54
|
+
throw new Error("Conflicting Submission admission identity");
|
|
55
|
+
}
|
|
56
|
+
const row = byKey ?? byRequest;
|
|
57
|
+
return row ? mapRow(row) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
begin(
|
|
61
|
+
requestId: string,
|
|
62
|
+
idempotencyKey: string | null,
|
|
63
|
+
runId: string,
|
|
64
|
+
attemptedAt: number,
|
|
65
|
+
): StoredSubmissionAdmission {
|
|
66
|
+
const existing = this.find(requestId, idempotencyKey ?? undefined);
|
|
67
|
+
if (existing) return existing;
|
|
68
|
+
this.sql`INSERT INTO pi_submission_admissions
|
|
69
|
+
(request_id, idempotency_key, run_id, status, account_id,
|
|
70
|
+
rate_version, slot_identity, code, retryable, attempted_at)
|
|
71
|
+
VALUES (${requestId}, ${idempotencyKey}, ${runId}, 'pending', NULL, NULL,
|
|
72
|
+
NULL, NULL, NULL, ${attemptedAt})`;
|
|
73
|
+
return this.find(requestId, idempotencyKey ?? undefined)!;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 记录一次针对已有 pending 行的新尝试。
|
|
78
|
+
*
|
|
79
|
+
* 只有仍在 pending 的行需要它:`attempted_at` 是在途名额的凭据,重投必须把
|
|
80
|
+
* 名额重新占住,否则并发重投可以突破 pending 上限。
|
|
81
|
+
*/
|
|
82
|
+
recordAttempt(requestId: string, attemptedAt: number): void {
|
|
83
|
+
this.sql`UPDATE pi_submission_admissions
|
|
84
|
+
SET attempted_at = ${attemptedAt}
|
|
85
|
+
WHERE request_id = ${requestId} AND status = 'pending'`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
saveAccepted(
|
|
89
|
+
requestId: string,
|
|
90
|
+
runId: string,
|
|
91
|
+
accountId: string,
|
|
92
|
+
rateVersion: number,
|
|
93
|
+
slotIdentity: string,
|
|
94
|
+
): void {
|
|
95
|
+
this.sql`UPDATE pi_submission_admissions
|
|
96
|
+
SET run_id = ${runId}, status = 'accepted', account_id = ${accountId},
|
|
97
|
+
rate_version = ${rateVersion}, slot_identity = ${slotIdentity}, code = NULL,
|
|
98
|
+
retryable = NULL
|
|
99
|
+
WHERE request_id = ${requestId} AND run_id = ${runId} AND status = 'pending'`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
saveRejected(
|
|
103
|
+
requestId: string,
|
|
104
|
+
runId: string,
|
|
105
|
+
code: string,
|
|
106
|
+
retryable: boolean,
|
|
107
|
+
): void {
|
|
108
|
+
this.sql`UPDATE pi_submission_admissions
|
|
109
|
+
SET run_id = ${runId}, status = 'rejected', code = ${code},
|
|
110
|
+
retryable = ${retryable ? 1 : 0}
|
|
111
|
+
WHERE request_id = ${requestId} AND run_id = ${runId} AND status = 'pending'`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 统计仍在等待 Host 结论、且最后一次尝试发生在 `attemptedSince` 之后的准入。
|
|
116
|
+
*
|
|
117
|
+
* 这些行代表在途的名额预留。停在 pending 的行不会被删除 —— 它固定了同一
|
|
118
|
+
* identity 重投时必须复用的 runId —— 但一次失败或崩溃留下的行不能永久占用
|
|
119
|
+
* 容量,所以计数按最后一次尝试时间截断,过期的行只作为身份记录留存。
|
|
120
|
+
*/
|
|
121
|
+
countPending(attemptedSince = 0): number {
|
|
122
|
+
return this.sql<{ count: number }>`
|
|
123
|
+
SELECT COUNT(*) AS count FROM pi_submission_admissions
|
|
124
|
+
WHERE status = 'pending' AND attempted_at >= ${attemptedSince}
|
|
125
|
+
`[0]?.count ?? 0;
|
|
126
|
+
}
|
|
127
|
+
}
|