@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/adapter/cloudflare/sandbox/adapter.ts +61 -36
  3. package/src/adapter/cloudflare/universal-agent/preparation.ts +0 -2
  4. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +23 -18
  5. package/src/db/index.ts +5 -0
  6. package/src/db/schema.ts +15 -0
  7. package/src/db/telemetry-outbox.repo.ts +151 -0
  8. package/src/index.ts +1 -0
  9. package/src/kernel/approval-lifecycle.ts +35 -3
  10. package/src/kernel/bindings.ts +0 -1
  11. package/src/kernel/interaction-lifecycle.ts +35 -6
  12. package/src/layers/orchestration/temporary-agent/workspace.ts +4 -4
  13. package/src/lib/prompt.ts +22 -15
  14. package/src/pi/assembly/context.ts +2 -2
  15. package/src/pi/message/conversion.ts +13 -1
  16. package/src/pi/runtime-adapter/execution.ts +26 -26
  17. package/src/pi/runtime-adapter/models.ts +144 -44
  18. package/src/pi/tool/ai-adapter.ts +2 -2
  19. package/src/pi/tool/base.ts +31 -25
  20. package/src/pi/tool/compiler.ts +5 -103
  21. package/src/pi/turn/tool-recovery.ts +11 -1
  22. package/src/runtime-agent.ts +24 -0
  23. package/src/runtime-assembler.ts +29 -15
  24. package/src/runtime-definition.ts +2 -0
  25. package/src/runtime.ts +362 -20
  26. package/src/telemetry/contract.ts +389 -0
  27. package/src/telemetry/coordinator.ts +143 -0
  28. package/src/telemetry/delivery.ts +138 -0
  29. package/src/telemetry/ids.ts +60 -0
  30. package/src/telemetry/index.ts +7 -0
  31. package/src/telemetry/recorder.ts +61 -0
  32. package/src/telemetry/runtime-telemetry.ts +484 -0
  33. package/src/telemetry/sanitize.ts +97 -0
  34. package/src/tool-registry.ts +11 -11
  35. package/src/lib/telemetry-dev.ts +0 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.2.0-alpha.16",
3
+ "version": "0.2.0-alpha.17",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -436,10 +436,10 @@ function mappedError(error: unknown): SandboxPortError {
436
436
 
437
437
  function workspaceToSandboxPath(
438
438
  path: string,
439
- root: "/" | "/shared",
439
+ root: "/" | "/userspace",
440
440
  ): string {
441
- if (root === "/shared") {
442
- return path === "/shared" ? "/shared" : 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", "/shared", "/tmp"],
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", "/shared", "/tmp"],
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
- if (this.manifest) {
888
- return this.freezeSyncResult({
889
- files: [],
890
- skipped: [],
891
- failed: [],
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 existing = await this.readHydrationManifest();
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(): Promise<SandboxSyncResult> {
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("/shared");
954
+ await this.client.mkdir("/userspace");
950
955
 
951
- for (const root of ["/", "/shared"] as const) {
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
- result.bytes + entry.size >
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
- result.bytes + bytes.byteLength >
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("/shared", item.path)
1100
- ? "/shared"
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("/shared", path)
1369
- ? "/shared"
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("/shared", selectedPath)
1406
- ? "/shared"
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 shared mount plus its own directory. All other
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("/shared", { recursive: true });
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. User-level `/shared` belongs to the
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 user-level `/shared` tree. */
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 === "/shared" || normalized.startsWith("/shared/")) {
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 (isReservedMemoryPath(physical)) {
130
- throw new Error("/shared/memories is read-only in Session workspaces");
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 === "/shared" || path.startsWith("/shared/")) {
138
- root = "/shared";
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 === "/shared" || physical.startsWith("/shared/")) {
222
- throw new Error("/shared is read-only for Sandbox publishing");
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 === "/shared" || physical === this.sessionRoot) {
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 `/shared` could target this Session's physical
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") {
@@ -953,7 +953,6 @@ export interface RuntimePlatformPort {
953
953
  * 出口由 Host 通过 Service Binding 等平台边界控制,不使用无约束的 Runtime 全局网络能力。
954
954
  */
955
955
  outbound: () => Fetcher;
956
- telemetryConsole?: boolean;
957
956
  /**
958
957
  * 在 Pi 工具真正执行前请 Host 审查本次调用。
959
958
  *