@springbrand/agent-runtime 0.2.0-alpha.16 → 0.2.0-alpha.17
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 +1 -1
- package/src/adapter/cloudflare/sandbox/adapter.ts +61 -36
- package/src/adapter/cloudflare/universal-agent/preparation.ts +0 -2
- package/src/adapter/cloudflare/workspace/scoped-workspace.ts +23 -18
- package/src/db/index.ts +5 -0
- package/src/db/schema.ts +15 -0
- package/src/db/telemetry-outbox.repo.ts +151 -0
- package/src/index.ts +1 -0
- package/src/kernel/approval-lifecycle.ts +35 -3
- package/src/kernel/bindings.ts +0 -1
- package/src/kernel/interaction-lifecycle.ts +35 -6
- package/src/layers/orchestration/temporary-agent/workspace.ts +4 -4
- package/src/lib/prompt.ts +22 -15
- package/src/pi/assembly/context.ts +2 -2
- package/src/pi/message/conversion.ts +13 -1
- package/src/pi/runtime-adapter/execution.ts +26 -26
- package/src/pi/runtime-adapter/models.ts +144 -44
- package/src/pi/tool/ai-adapter.ts +2 -2
- package/src/pi/tool/base.ts +31 -25
- package/src/pi/tool/compiler.ts +5 -103
- package/src/pi/turn/tool-recovery.ts +11 -1
- package/src/runtime-agent.ts +24 -0
- package/src/runtime-assembler.ts +29 -15
- package/src/runtime-definition.ts +2 -0
- package/src/runtime.ts +362 -20
- package/src/telemetry/contract.ts +389 -0
- package/src/telemetry/coordinator.ts +143 -0
- package/src/telemetry/delivery.ts +138 -0
- package/src/telemetry/ids.ts +60 -0
- package/src/telemetry/index.ts +7 -0
- package/src/telemetry/recorder.ts +61 -0
- package/src/telemetry/runtime-telemetry.ts +484 -0
- package/src/telemetry/sanitize.ts +97 -0
- package/src/tool-registry.ts +11 -11
- package/src/lib/telemetry-dev.ts +0 -47
package/package.json
CHANGED
|
@@ -436,10 +436,10 @@ function mappedError(error: unknown): SandboxPortError {
|
|
|
436
436
|
|
|
437
437
|
function workspaceToSandboxPath(
|
|
438
438
|
path: string,
|
|
439
|
-
root: "/" | "/
|
|
439
|
+
root: "/" | "/userspace",
|
|
440
440
|
): string {
|
|
441
|
-
if (root === "/
|
|
442
|
-
return path === "/
|
|
441
|
+
if (root === "/userspace") {
|
|
442
|
+
return path === "/userspace" ? "/userspace" : path;
|
|
443
443
|
}
|
|
444
444
|
return path === "/" ? "/workspace" : `/workspace${path}`;
|
|
445
445
|
}
|
|
@@ -493,7 +493,7 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
493
493
|
await this.ensureHydrated();
|
|
494
494
|
const cwd = normalizedPath(
|
|
495
495
|
input.cwd,
|
|
496
|
-
["/workspace", "/
|
|
496
|
+
["/workspace", "/userspace", "/tmp"],
|
|
497
497
|
"/workspace",
|
|
498
498
|
);
|
|
499
499
|
const timeout = Math.min(
|
|
@@ -564,7 +564,7 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
564
564
|
await this.ensureHydrated();
|
|
565
565
|
const cwd = normalizedPath(
|
|
566
566
|
input.cwd,
|
|
567
|
-
["/workspace", "/
|
|
567
|
+
["/workspace", "/userspace", "/tmp"],
|
|
568
568
|
"/workspace",
|
|
569
569
|
);
|
|
570
570
|
const session = await this.ensureSession(
|
|
@@ -880,32 +880,32 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
880
880
|
return next;
|
|
881
881
|
}
|
|
882
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Make the container's copy of the Workspace current.
|
|
885
|
+
*
|
|
886
|
+
* A hydrated container used to be treated as done, forever. That is only
|
|
887
|
+
* true if nothing else can change the Workspace — and something always can:
|
|
888
|
+
* the Agent writes files between commands, and a person can restore an
|
|
889
|
+
* earlier version at any time. A script would then run against files its
|
|
890
|
+
* author had already replaced, and produce a confidently wrong answer with no
|
|
891
|
+
* error anywhere.
|
|
892
|
+
*
|
|
893
|
+
* So every hydration reconciles. The manifest records each copied file's size
|
|
894
|
+
* and modification time, so an unchanged Workspace transfers nothing and only
|
|
895
|
+
* what actually moved is copied again.
|
|
896
|
+
*/
|
|
883
897
|
private async ensureHydrated(): Promise<SandboxSyncResult> {
|
|
884
898
|
if (!this.hydration) {
|
|
885
899
|
this.hydration = (async () => {
|
|
886
900
|
try {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
bytes: 0,
|
|
901
|
+
const previous =
|
|
902
|
+
this.manifest ?? (await this.readHydrationManifest());
|
|
903
|
+
if (!previous) {
|
|
904
|
+
this.emit("sandbox.container.cold_start", {
|
|
905
|
+
markerMissing: true,
|
|
893
906
|
});
|
|
894
907
|
}
|
|
895
|
-
const
|
|
896
|
-
if (existing) {
|
|
897
|
-
this.manifest = existing;
|
|
898
|
-
return this.freezeSyncResult({
|
|
899
|
-
files: [],
|
|
900
|
-
skipped: [],
|
|
901
|
-
failed: [],
|
|
902
|
-
bytes: 0,
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
this.emit("sandbox.container.cold_start", {
|
|
906
|
-
markerMissing: true,
|
|
907
|
-
});
|
|
908
|
-
const result = await this.pullWorkspaceInternal();
|
|
908
|
+
const result = await this.pullWorkspaceInternal(previous);
|
|
909
909
|
if (result.failed.length > 0) {
|
|
910
910
|
throw new Error("Workspace hydration was partial");
|
|
911
911
|
}
|
|
@@ -929,7 +929,9 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
929
929
|
return this.hydration;
|
|
930
930
|
}
|
|
931
931
|
|
|
932
|
-
private async pullWorkspaceInternal(
|
|
932
|
+
private async pullWorkspaceInternal(
|
|
933
|
+
previous?: HydrationManifest | null,
|
|
934
|
+
): Promise<SandboxSyncResult> {
|
|
933
935
|
const result: {
|
|
934
936
|
files: string[];
|
|
935
937
|
skipped: string[];
|
|
@@ -945,10 +947,13 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
945
947
|
files: new Map(),
|
|
946
948
|
};
|
|
947
949
|
let visitedEntries = 0;
|
|
950
|
+
// Counted separately from `result.bytes`: the cap is about how much the
|
|
951
|
+
// container holds, while `result.bytes` reports what this pass moved.
|
|
952
|
+
let hydratedBytes = 0;
|
|
948
953
|
await this.client.mkdir("/workspace");
|
|
949
|
-
await this.client.mkdir("/
|
|
954
|
+
await this.client.mkdir("/userspace");
|
|
950
955
|
|
|
951
|
-
for (const root of ["/", "/
|
|
956
|
+
for (const root of ["/", "/userspace"] as const) {
|
|
952
957
|
const pending: string[] = [root];
|
|
953
958
|
while (pending.length > 0) {
|
|
954
959
|
const directory = pending.pop()!;
|
|
@@ -1003,7 +1008,7 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
1003
1008
|
);
|
|
1004
1009
|
}
|
|
1005
1010
|
if (
|
|
1006
|
-
|
|
1011
|
+
hydratedBytes + entry.size >
|
|
1007
1012
|
SANDBOX_HOST_POLICY.maxHydrationBytes
|
|
1008
1013
|
) {
|
|
1009
1014
|
throw new SandboxPortError(
|
|
@@ -1011,6 +1016,18 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
1011
1016
|
"Workspace hydration exceeds the total byte limit",
|
|
1012
1017
|
);
|
|
1013
1018
|
}
|
|
1019
|
+
const copied = previous?.files.get(destination);
|
|
1020
|
+
if (
|
|
1021
|
+
copied &&
|
|
1022
|
+
copied.updatedAt === entry.updatedAt &&
|
|
1023
|
+
copied.size === entry.size &&
|
|
1024
|
+
(await this.client.exists(destination))
|
|
1025
|
+
) {
|
|
1026
|
+
// Already the same bytes in the container. Nothing to move.
|
|
1027
|
+
currentManifest.files.set(destination, copied);
|
|
1028
|
+
hydratedBytes += entry.size;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1014
1031
|
const bytes =
|
|
1015
1032
|
await this.options.workspace.readFileBytes(sourcePath);
|
|
1016
1033
|
if (!bytes) {
|
|
@@ -1022,7 +1039,7 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
1022
1039
|
}
|
|
1023
1040
|
if (
|
|
1024
1041
|
bytes.byteLength > MAX_PUBLISH_FILE_BYTES ||
|
|
1025
|
-
|
|
1042
|
+
hydratedBytes + bytes.byteLength >
|
|
1026
1043
|
SANDBOX_HOST_POLICY.maxHydrationBytes
|
|
1027
1044
|
) {
|
|
1028
1045
|
throw new SandboxPortError(
|
|
@@ -1041,6 +1058,7 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
1041
1058
|
});
|
|
1042
1059
|
result.files.push(destination);
|
|
1043
1060
|
result.bytes += bytes.byteLength;
|
|
1061
|
+
hydratedBytes += bytes.byteLength;
|
|
1044
1062
|
}
|
|
1045
1063
|
if (entries.length < WORKSPACE_PAGE_SIZE) break;
|
|
1046
1064
|
offset += entries.length;
|
|
@@ -1048,6 +1066,13 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
1048
1066
|
}
|
|
1049
1067
|
}
|
|
1050
1068
|
if (result.failed.length === 0) {
|
|
1069
|
+
// A file the Workspace no longer has must not survive in the container:
|
|
1070
|
+
// a script globbing a directory would still find it and treat deleted
|
|
1071
|
+
// work as current.
|
|
1072
|
+
for (const path of previous?.files.keys() ?? []) {
|
|
1073
|
+
if (currentManifest.files.has(path)) continue;
|
|
1074
|
+
await this.client.deleteFile(path).catch(() => undefined);
|
|
1075
|
+
}
|
|
1051
1076
|
await this.writeHydrationManifest(currentManifest);
|
|
1052
1077
|
}
|
|
1053
1078
|
return this.freezeSyncResult(result);
|
|
@@ -1096,8 +1121,8 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
1096
1121
|
) {
|
|
1097
1122
|
return null;
|
|
1098
1123
|
}
|
|
1099
|
-
const root = pathInside("/
|
|
1100
|
-
? "/
|
|
1124
|
+
const root = pathInside("/userspace", item.path)
|
|
1125
|
+
? "/userspace"
|
|
1101
1126
|
: "/workspace";
|
|
1102
1127
|
files.set(canonicalPathInside(item.path, root), {
|
|
1103
1128
|
updatedAt: item.updatedAt,
|
|
@@ -1365,8 +1390,8 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
1365
1390
|
private async assertNoSymlinkComponents(
|
|
1366
1391
|
path: string,
|
|
1367
1392
|
): Promise<void> {
|
|
1368
|
-
const root = pathInside("/
|
|
1369
|
-
? "/
|
|
1393
|
+
const root = pathInside("/userspace", path)
|
|
1394
|
+
? "/userspace"
|
|
1370
1395
|
: "/workspace";
|
|
1371
1396
|
if (path === root) return;
|
|
1372
1397
|
|
|
@@ -1402,8 +1427,8 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
1402
1427
|
selectedPath: string,
|
|
1403
1428
|
entry: SandboxFileEntry,
|
|
1404
1429
|
): void {
|
|
1405
|
-
const root = pathInside("/
|
|
1406
|
-
? "/
|
|
1430
|
+
const root = pathInside("/userspace", selectedPath)
|
|
1431
|
+
? "/userspace"
|
|
1407
1432
|
: "/workspace";
|
|
1408
1433
|
const absolutePath = canonicalPathInside(
|
|
1409
1434
|
entry.absolutePath,
|
|
@@ -34,7 +34,6 @@ export type PlatformLoader = () => Promise<RuntimePlatformPort>;
|
|
|
34
34
|
export interface CloudflarePlatformBindings {
|
|
35
35
|
LOADER: WorkerLoader;
|
|
36
36
|
BROWSER?: RuntimeBrowserPort;
|
|
37
|
-
TELEMETRY_CONSOLE?: string;
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
export interface WorkspaceRequirement {
|
|
@@ -110,7 +109,6 @@ export function createPlatformLoader<
|
|
|
110
109
|
loader: context.env.LOADER,
|
|
111
110
|
...(context.env.BROWSER ? { browser: context.env.BROWSER } : {}),
|
|
112
111
|
outbound: () => exports.HttpGateway({}),
|
|
113
|
-
telemetryConsole: context.env.TELEMETRY_CONSOLE === "1",
|
|
114
112
|
};
|
|
115
113
|
});
|
|
116
114
|
}
|
|
@@ -27,12 +27,8 @@ function normalizePath(path: string): string {
|
|
|
27
27
|
return `/${parts.join("/")}`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function isReservedMemoryPath(path: string): boolean {
|
|
31
|
-
return path === "/shared/memories" || path.startsWith("/shared/memories/");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
30
|
/**
|
|
35
|
-
* A Session sees the user's
|
|
31
|
+
* A Session sees the user's UserSpace mount plus its own directory. All other
|
|
36
32
|
* paths are interpreted relative to the current Session directory; another
|
|
37
33
|
* Session can never be named through this port. The Inbox Host also uses this
|
|
38
34
|
* object to create and remove the Session tree, so callers never need to build
|
|
@@ -45,6 +41,8 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
45
41
|
private readonly parent: WorkspacePort,
|
|
46
42
|
readonly sessionId: string,
|
|
47
43
|
private readonly quota: WorkspaceQuota = UNCONFIGURED_QUOTA,
|
|
44
|
+
/** Physical storage root; the runtime mount remains `/userspace`. */
|
|
45
|
+
private readonly userSpaceRoot: "/userspace" | "/shared" = "/userspace",
|
|
48
46
|
) {
|
|
49
47
|
if (!sessionId || sessionId.includes("/") || sessionId === "." || sessionId === "..") {
|
|
50
48
|
throw new Error("invalid session id");
|
|
@@ -53,12 +51,12 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
async ensure(): Promise<void> {
|
|
56
|
-
await this.parent.mkdir(
|
|
54
|
+
await this.parent.mkdir(this.userSpaceRoot, { recursive: true });
|
|
57
55
|
await this.parent.mkdir(this.sessionRoot, { recursive: true });
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
/**
|
|
61
|
-
* Count only this Session's private tree.
|
|
59
|
+
* Count only this Session's private tree. `/userspace` belongs to the user and
|
|
62
60
|
* Inbox and must not be charged once per Chat.
|
|
63
61
|
*/
|
|
64
62
|
async getUsage(): Promise<WorkspaceUsage> {
|
|
@@ -100,7 +98,7 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
100
98
|
return { ...this.quota };
|
|
101
99
|
}
|
|
102
100
|
|
|
103
|
-
/** Host-only lifecycle operation. It never removes the
|
|
101
|
+
/** Host-only lifecycle operation. It never removes the UserSpace tree. */
|
|
104
102
|
async removeAll(): Promise<void> {
|
|
105
103
|
await this.parent.rm(this.sessionRoot, {
|
|
106
104
|
recursive: true,
|
|
@@ -110,8 +108,8 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
110
108
|
|
|
111
109
|
private physical(path: string): string {
|
|
112
110
|
const normalized = normalizePath(path);
|
|
113
|
-
if (normalized === "/
|
|
114
|
-
return normalized
|
|
111
|
+
if (normalized === "/userspace" || normalized.startsWith("/userspace/")) {
|
|
112
|
+
return `${this.userSpaceRoot}${normalized.slice("/userspace".length)}`;
|
|
115
113
|
}
|
|
116
114
|
if (normalized === this.sessionRoot || normalized.startsWith(`${this.sessionRoot}/`)) {
|
|
117
115
|
return normalized;
|
|
@@ -126,16 +124,19 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
126
124
|
|
|
127
125
|
private writablePhysical(path: string): string {
|
|
128
126
|
const physical = this.physical(path);
|
|
129
|
-
if (
|
|
130
|
-
|
|
127
|
+
if (
|
|
128
|
+
physical === `${this.userSpaceRoot}/memories` ||
|
|
129
|
+
physical.startsWith(`${this.userSpaceRoot}/memories/`)
|
|
130
|
+
) {
|
|
131
|
+
throw new Error("/userspace/memories is read-only in Session workspaces");
|
|
131
132
|
}
|
|
132
133
|
return physical;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
private async assertNoSymlinkComponents(path: string): Promise<void> {
|
|
136
137
|
let root: string;
|
|
137
|
-
if (path ===
|
|
138
|
-
root =
|
|
138
|
+
if (path === this.userSpaceRoot || path.startsWith(`${this.userSpaceRoot}/`)) {
|
|
139
|
+
root = this.userSpaceRoot;
|
|
139
140
|
} else if (
|
|
140
141
|
path === this.sessionRoot ||
|
|
141
142
|
path.startsWith(`${this.sessionRoot}/`)
|
|
@@ -172,6 +173,10 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
private virtual(path: string): string {
|
|
176
|
+
if (path === this.userSpaceRoot) return "/userspace";
|
|
177
|
+
if (path.startsWith(`${this.userSpaceRoot}/`)) {
|
|
178
|
+
return `/userspace${path.slice(this.userSpaceRoot.length)}`;
|
|
179
|
+
}
|
|
175
180
|
if (path === this.sessionRoot) return "/";
|
|
176
181
|
if (path.startsWith(`${this.sessionRoot}/`)) {
|
|
177
182
|
return path.slice(this.sessionRoot.length);
|
|
@@ -218,8 +223,8 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
218
223
|
expected: WorkspaceFileVersion | null,
|
|
219
224
|
): Promise<WorkspaceConditionalWriteResult> {
|
|
220
225
|
const physical = await this.guardedPhysical(path, true);
|
|
221
|
-
if (physical ===
|
|
222
|
-
throw new Error("/
|
|
226
|
+
if (physical === this.userSpaceRoot || physical.startsWith(`${this.userSpaceRoot}/`)) {
|
|
227
|
+
throw new Error("/userspace is read-only for Sandbox publishing");
|
|
223
228
|
}
|
|
224
229
|
const current = await this.parent.stat(physical);
|
|
225
230
|
const currentVersion =
|
|
@@ -291,7 +296,7 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
291
296
|
|
|
292
297
|
async rm(path: string, opts?: { recursive?: boolean; force?: boolean }) {
|
|
293
298
|
const physical = await this.guardedPhysical(path, true);
|
|
294
|
-
if (physical ===
|
|
299
|
+
if (physical === this.userSpaceRoot || physical === this.sessionRoot) {
|
|
295
300
|
throw new Error("workspace mount roots cannot be removed");
|
|
296
301
|
}
|
|
297
302
|
return this.parent.rm(physical, opts);
|
|
@@ -315,7 +320,7 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
|
|
|
315
320
|
|
|
316
321
|
async symlink(target: string, linkPath: string) {
|
|
317
322
|
/*
|
|
318
|
-
* A symlink stored under `/
|
|
323
|
+
* A symlink stored under `/userspace` could target this Session's physical
|
|
319
324
|
* directory and then be followed by another Session. Path normalization
|
|
320
325
|
* cannot guard a later filesystem dereference, so scoped runtimes do not
|
|
321
326
|
* create symlinks at all.
|
package/src/db/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { ExtContextRepository } from "./ext-context.repo";
|
|
|
10
10
|
import { MessageUiRepository } from "./message-ui.repo";
|
|
11
11
|
import { SteerRepository } from "./steer.repo";
|
|
12
12
|
import { RuntimeEventOutboxRepository } from "./runtime-event-outbox.repo";
|
|
13
|
+
import { TelemetryOutboxRepository } from "./telemetry-outbox.repo";
|
|
13
14
|
import { AgentToolRepository } from "./agent-tool.repo";
|
|
14
15
|
import { SubmissionAdmissionRepository } from "./submission-admission.repo";
|
|
15
16
|
|
|
@@ -37,6 +38,7 @@ export * from "./ext-context.repo";
|
|
|
37
38
|
export * from "./message-ui.repo";
|
|
38
39
|
export * from "./steer.repo";
|
|
39
40
|
export * from "./runtime-event-outbox.repo";
|
|
41
|
+
export * from "./telemetry-outbox.repo";
|
|
40
42
|
export * from "./agent-tool.repo";
|
|
41
43
|
export * from "./submission-admission.repo";
|
|
42
44
|
|
|
@@ -50,6 +52,7 @@ export class RuntimeDatabase {
|
|
|
50
52
|
readonly messageUi: MessageUiRepository;
|
|
51
53
|
readonly steers: SteerRepository;
|
|
52
54
|
readonly runtimeEvents: RuntimeEventOutboxRepository;
|
|
55
|
+
readonly telemetry: TelemetryOutboxRepository;
|
|
53
56
|
readonly agentTools: AgentToolRepository;
|
|
54
57
|
readonly submissionAdmissions: SubmissionAdmissionRepository;
|
|
55
58
|
|
|
@@ -69,6 +72,7 @@ export class RuntimeDatabase {
|
|
|
69
72
|
this.messageUi = new MessageUiRepository(sql);
|
|
70
73
|
this.steers = new SteerRepository(sql);
|
|
71
74
|
this.runtimeEvents = new RuntimeEventOutboxRepository(sql);
|
|
75
|
+
this.telemetry = new TelemetryOutboxRepository(sql);
|
|
72
76
|
this.agentTools = new AgentToolRepository(sql);
|
|
73
77
|
this.submissionAdmissions = new SubmissionAdmissionRepository(sql);
|
|
74
78
|
}
|
|
@@ -116,5 +120,6 @@ export class RuntimeDatabase {
|
|
|
116
120
|
this.sql`DELETE FROM pi_tool_interactions`;
|
|
117
121
|
this.sql`DELETE FROM pi_tool_settlements`;
|
|
118
122
|
this.sql`DELETE FROM pi_recovery_milestones`;
|
|
123
|
+
this.sql`DELETE FROM pi_telemetry_outbox`;
|
|
119
124
|
}
|
|
120
125
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -94,6 +94,21 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
94
94
|
created_at INTEGER NOT NULL,
|
|
95
95
|
delivered_at INTEGER
|
|
96
96
|
)`;
|
|
97
|
+
sql`CREATE TABLE IF NOT EXISTS pi_telemetry_outbox (
|
|
98
|
+
event_id TEXT PRIMARY KEY,
|
|
99
|
+
body TEXT NOT NULL,
|
|
100
|
+
created_at INTEGER NOT NULL,
|
|
101
|
+
delivered_at INTEGER,
|
|
102
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
103
|
+
quarantined_at INTEGER,
|
|
104
|
+
last_error TEXT
|
|
105
|
+
)`;
|
|
106
|
+
sql`CREATE INDEX IF NOT EXISTS pi_telemetry_outbox_pending
|
|
107
|
+
ON pi_telemetry_outbox(created_at, event_id)
|
|
108
|
+
WHERE delivered_at IS NULL AND quarantined_at IS NULL`;
|
|
109
|
+
sql`CREATE INDEX IF NOT EXISTS pi_telemetry_outbox_delivered
|
|
110
|
+
ON pi_telemetry_outbox(delivered_at)
|
|
111
|
+
WHERE delivered_at IS NOT NULL`;
|
|
97
112
|
sql`CREATE TABLE IF NOT EXISTS pi_submission_admissions (
|
|
98
113
|
request_id TEXT PRIMARY KEY,
|
|
99
114
|
idempotency_key TEXT UNIQUE,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { SqlTaggedTemplate } from "agents/chat";
|
|
2
|
+
|
|
3
|
+
export const TELEMETRY_OUTBOX_PENDING_LIMIT = 10_000;
|
|
4
|
+
export const TELEMETRY_OUTBOX_BATCH_SIZE = 50;
|
|
5
|
+
|
|
6
|
+
export interface NewTelemetryOutboxEvent {
|
|
7
|
+
readonly eventId: string;
|
|
8
|
+
readonly body: string;
|
|
9
|
+
readonly createdAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StoredTelemetryOutboxEvent extends NewTelemetryOutboxEvent {
|
|
13
|
+
readonly deliveredAt: number | null;
|
|
14
|
+
readonly attemptCount: number;
|
|
15
|
+
readonly quarantinedAt: number | null;
|
|
16
|
+
readonly lastError: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type TelemetryOutboxInsertResult =
|
|
20
|
+
| "inserted"
|
|
21
|
+
| "duplicate"
|
|
22
|
+
| "full";
|
|
23
|
+
|
|
24
|
+
type TelemetryOutboxRow = {
|
|
25
|
+
event_id: string;
|
|
26
|
+
body: string;
|
|
27
|
+
created_at: number;
|
|
28
|
+
delivered_at: number | null;
|
|
29
|
+
attempt_count: number;
|
|
30
|
+
quarantined_at: number | null;
|
|
31
|
+
last_error: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function mapRow(row: TelemetryOutboxRow): StoredTelemetryOutboxEvent {
|
|
35
|
+
return {
|
|
36
|
+
eventId: row.event_id,
|
|
37
|
+
body: row.body,
|
|
38
|
+
createdAt: row.created_at,
|
|
39
|
+
deliveredAt: row.delivered_at,
|
|
40
|
+
attemptCount: row.attempt_count,
|
|
41
|
+
quarantinedAt: row.quarantined_at,
|
|
42
|
+
lastError: row.last_error,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class TelemetryOutboxRepository {
|
|
47
|
+
constructor(private readonly sql: SqlTaggedTemplate) {}
|
|
48
|
+
|
|
49
|
+
insert(
|
|
50
|
+
event: NewTelemetryOutboxEvent,
|
|
51
|
+
pendingLimit = TELEMETRY_OUTBOX_PENDING_LIMIT,
|
|
52
|
+
): TelemetryOutboxInsertResult {
|
|
53
|
+
const existing = this.find(event.eventId);
|
|
54
|
+
if (existing) {
|
|
55
|
+
if (existing.body !== event.body) {
|
|
56
|
+
throw new Error(`Conflicting telemetry event: ${event.eventId}`);
|
|
57
|
+
}
|
|
58
|
+
return "duplicate";
|
|
59
|
+
}
|
|
60
|
+
if (this.pendingCount() >= pendingLimit) return "full";
|
|
61
|
+
this.sql`
|
|
62
|
+
INSERT INTO pi_telemetry_outbox (
|
|
63
|
+
event_id, body, created_at, delivered_at,
|
|
64
|
+
attempt_count, quarantined_at, last_error
|
|
65
|
+
) VALUES (
|
|
66
|
+
${event.eventId}, ${event.body}, ${event.createdAt}, NULL,
|
|
67
|
+
0, NULL, NULL
|
|
68
|
+
)
|
|
69
|
+
`;
|
|
70
|
+
return "inserted";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
find(eventId: string): StoredTelemetryOutboxEvent | null {
|
|
74
|
+
const row = this.sql<TelemetryOutboxRow>`
|
|
75
|
+
SELECT event_id, body, created_at, delivered_at,
|
|
76
|
+
attempt_count, quarantined_at, last_error
|
|
77
|
+
FROM pi_telemetry_outbox
|
|
78
|
+
WHERE event_id = ${eventId}
|
|
79
|
+
`[0];
|
|
80
|
+
return row ? mapRow(row) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
listPending(
|
|
84
|
+
limit = TELEMETRY_OUTBOX_BATCH_SIZE,
|
|
85
|
+
): StoredTelemetryOutboxEvent[] {
|
|
86
|
+
return this.sql<TelemetryOutboxRow>`
|
|
87
|
+
SELECT event_id, body, created_at, delivered_at,
|
|
88
|
+
attempt_count, quarantined_at, last_error
|
|
89
|
+
FROM pi_telemetry_outbox
|
|
90
|
+
WHERE delivered_at IS NULL AND quarantined_at IS NULL
|
|
91
|
+
ORDER BY created_at, event_id
|
|
92
|
+
LIMIT ${limit}
|
|
93
|
+
`.map(mapRow);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pendingCount(): number {
|
|
97
|
+
return this.sql<{ count: number }>`
|
|
98
|
+
SELECT COUNT(*) AS count
|
|
99
|
+
FROM pi_telemetry_outbox
|
|
100
|
+
WHERE delivered_at IS NULL AND quarantined_at IS NULL
|
|
101
|
+
`[0]?.count ?? 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
hasPending(): boolean {
|
|
105
|
+
return Boolean(
|
|
106
|
+
this.sql<{ pending: number }>`
|
|
107
|
+
SELECT 1 AS pending
|
|
108
|
+
FROM pi_telemetry_outbox
|
|
109
|
+
WHERE delivered_at IS NULL AND quarantined_at IS NULL
|
|
110
|
+
LIMIT 1
|
|
111
|
+
`[0],
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
markDelivered(eventIds: readonly string[], deliveredAt: number): void {
|
|
116
|
+
for (const eventId of eventIds) {
|
|
117
|
+
this.sql`
|
|
118
|
+
UPDATE pi_telemetry_outbox
|
|
119
|
+
SET delivered_at = ${deliveredAt}, last_error = NULL
|
|
120
|
+
WHERE event_id = ${eventId}
|
|
121
|
+
AND delivered_at IS NULL AND quarantined_at IS NULL
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
markFailed(eventIds: readonly string[], error: string): void {
|
|
127
|
+
for (const eventId of eventIds) {
|
|
128
|
+
this.sql`
|
|
129
|
+
UPDATE pi_telemetry_outbox
|
|
130
|
+
SET attempt_count = attempt_count + 1, last_error = ${error}
|
|
131
|
+
WHERE event_id = ${eventId}
|
|
132
|
+
AND delivered_at IS NULL AND quarantined_at IS NULL
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
quarantine(eventId: string, quarantinedAt: number, error: string): void {
|
|
138
|
+
this.sql`
|
|
139
|
+
UPDATE pi_telemetry_outbox
|
|
140
|
+
SET quarantined_at = ${quarantinedAt}, last_error = ${error}
|
|
141
|
+
WHERE event_id = ${eventId} AND delivered_at IS NULL
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
deleteDeliveredBefore(cutoff: number): void {
|
|
146
|
+
this.sql`
|
|
147
|
+
DELETE FROM pi_telemetry_outbox
|
|
148
|
+
WHERE delivered_at IS NOT NULL AND delivered_at < ${cutoff}
|
|
149
|
+
`;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ export * from "./kernel/extensions";
|
|
|
47
47
|
export * from "./kernel/profile";
|
|
48
48
|
export * from "./kernel/receipts";
|
|
49
49
|
export * from "./kernel/runtime-config";
|
|
50
|
+
export * from "./telemetry";
|
|
50
51
|
export type { RuntimeAssemblyView } from "./kernel/runtime-assembly-view";
|
|
51
52
|
export * from "./runtime-agent";
|
|
52
53
|
export * from "./kernel/state";
|
|
@@ -83,6 +83,11 @@ interface ApprovalLifecycleOptions<
|
|
|
83
83
|
data: ApprovalContinuationData,
|
|
84
84
|
) => Promise<void>;
|
|
85
85
|
readonly onApprovalsChanged: () => Promise<void>;
|
|
86
|
+
readonly onTelemetryRequested?: (approval: ApprovalRecord) => void;
|
|
87
|
+
readonly onTelemetryDecided?: (
|
|
88
|
+
approval: ApprovalRecord,
|
|
89
|
+
decision: "approved" | "rejected" | "cancelled",
|
|
90
|
+
) => void;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
// #endregion
|
|
@@ -210,9 +215,17 @@ export class ApprovalLifecycle<
|
|
|
210
215
|
approval: ApprovalRecord,
|
|
211
216
|
) => void | Promise<void>,
|
|
212
217
|
): Promise<void> {
|
|
213
|
-
const pending = this.options.db.transaction(() =>
|
|
214
|
-
this.ensurePending(submission, approval)
|
|
215
|
-
|
|
218
|
+
const pending = this.options.db.transaction(() => {
|
|
219
|
+
const value = this.ensurePending(submission, approval);
|
|
220
|
+
if (value.created) {
|
|
221
|
+
try {
|
|
222
|
+
this.options.onTelemetryRequested?.(value.approval);
|
|
223
|
+
} catch {
|
|
224
|
+
// Observability must not change approval durability.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
});
|
|
216
229
|
if (pending.created) {
|
|
217
230
|
await this.options.onApprovalsChanged();
|
|
218
231
|
await onApproval?.(pending.approval);
|
|
@@ -313,6 +326,14 @@ export class ApprovalLifecycle<
|
|
|
313
326
|
submission,
|
|
314
327
|
decision.mutations,
|
|
315
328
|
);
|
|
329
|
+
const decided = this.read(stored.executionId);
|
|
330
|
+
if (decided) {
|
|
331
|
+
try {
|
|
332
|
+
this.options.onTelemetryDecided?.(decided, "cancelled");
|
|
333
|
+
} catch {
|
|
334
|
+
// Observability must not change terminal cleanup.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
316
337
|
}
|
|
317
338
|
return true;
|
|
318
339
|
}
|
|
@@ -376,6 +397,17 @@ export class ApprovalLifecycle<
|
|
|
376
397
|
submission,
|
|
377
398
|
[...pending.mutations, ...decision.mutations],
|
|
378
399
|
);
|
|
400
|
+
const decided = this.read(stored.executionId);
|
|
401
|
+
if (decided && decided.status !== "pending") {
|
|
402
|
+
try {
|
|
403
|
+
this.options.onTelemetryDecided?.(
|
|
404
|
+
decided,
|
|
405
|
+
decided.status === "approved" ? "approved" : "rejected",
|
|
406
|
+
);
|
|
407
|
+
} catch {
|
|
408
|
+
// Observability must not change approval decisions.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
379
411
|
});
|
|
380
412
|
const persisted = this.read(stored.executionId);
|
|
381
413
|
if (!persisted || persisted.status === "pending") {
|
package/src/kernel/bindings.ts
CHANGED