@shpitdev/convex-cliproxy-observability 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.
Files changed (129) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +201 -0
  3. package/README.md +40 -0
  4. package/dist/capture/index.d.ts +79 -0
  5. package/dist/capture/index.d.ts.map +1 -0
  6. package/dist/capture/index.js +182 -0
  7. package/dist/capture/index.js.map +1 -0
  8. package/dist/client.d.ts +129 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +232 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/component/_generated/api.d.ts +12 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -0
  14. package/dist/component/_generated/api.js +7 -0
  15. package/dist/component/_generated/api.js.map +1 -0
  16. package/dist/component/_generated/component.d.ts +17 -0
  17. package/dist/component/_generated/component.d.ts.map +1 -0
  18. package/dist/component/_generated/component.js +3 -0
  19. package/dist/component/_generated/component.js.map +1 -0
  20. package/dist/component/_generated/dataModel.d.ts +8 -0
  21. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  22. package/dist/component/_generated/dataModel.js +3 -0
  23. package/dist/component/_generated/dataModel.js.map +1 -0
  24. package/dist/component/_generated/server.d.ts +21 -0
  25. package/dist/component/_generated/server.d.ts.map +1 -0
  26. package/dist/component/_generated/server.js +11 -0
  27. package/dist/component/_generated/server.js.map +1 -0
  28. package/dist/component/convex.config.d.ts +4 -0
  29. package/dist/component/convex.config.d.ts.map +1 -0
  30. package/dist/component/convex.config.js +4 -0
  31. package/dist/component/convex.config.js.map +1 -0
  32. package/dist/component/ingest.d.ts +64 -0
  33. package/dist/component/ingest.d.ts.map +1 -0
  34. package/dist/component/ingest.js +309 -0
  35. package/dist/component/ingest.js.map +1 -0
  36. package/dist/component/queries.d.ts +136 -0
  37. package/dist/component/queries.d.ts.map +1 -0
  38. package/dist/component/queries.js +219 -0
  39. package/dist/component/queries.js.map +1 -0
  40. package/dist/component/schema.d.ts +159 -0
  41. package/dist/component/schema.d.ts.map +1 -0
  42. package/dist/component/schema.js +74 -0
  43. package/dist/component/schema.js.map +1 -0
  44. package/dist/content/index.d.ts +8 -0
  45. package/dist/content/index.d.ts.map +1 -0
  46. package/dist/content/index.js +41 -0
  47. package/dist/content/index.js.map +1 -0
  48. package/dist/model-call/index.d.ts +120 -0
  49. package/dist/model-call/index.d.ts.map +1 -0
  50. package/dist/model-call/index.js +96 -0
  51. package/dist/model-call/index.js.map +1 -0
  52. package/dist/projection.d.ts +19 -0
  53. package/dist/projection.d.ts.map +1 -0
  54. package/dist/projection.js +83 -0
  55. package/dist/projection.js.map +1 -0
  56. package/dist/protocols/anthropic-messages.d.ts +11 -0
  57. package/dist/protocols/anthropic-messages.d.ts.map +1 -0
  58. package/dist/protocols/anthropic-messages.js +114 -0
  59. package/dist/protocols/anthropic-messages.js.map +1 -0
  60. package/dist/protocols/chat-completions.d.ts +10 -0
  61. package/dist/protocols/chat-completions.d.ts.map +1 -0
  62. package/dist/protocols/chat-completions.js +89 -0
  63. package/dist/protocols/chat-completions.js.map +1 -0
  64. package/dist/protocols/checkpoint.d.ts +20 -0
  65. package/dist/protocols/checkpoint.d.ts.map +1 -0
  66. package/dist/protocols/checkpoint.js +318 -0
  67. package/dist/protocols/checkpoint.js.map +1 -0
  68. package/dist/protocols/content.d.ts +34 -0
  69. package/dist/protocols/content.d.ts.map +1 -0
  70. package/dist/protocols/content.js +40 -0
  71. package/dist/protocols/content.js.map +1 -0
  72. package/dist/protocols/framing.d.ts +20 -0
  73. package/dist/protocols/framing.d.ts.map +1 -0
  74. package/dist/protocols/framing.js +67 -0
  75. package/dist/protocols/framing.js.map +1 -0
  76. package/dist/protocols/index.d.ts +20 -0
  77. package/dist/protocols/index.d.ts.map +1 -0
  78. package/dist/protocols/index.js +139 -0
  79. package/dist/protocols/index.js.map +1 -0
  80. package/dist/protocols/output.d.ts +4 -0
  81. package/dist/protocols/output.d.ts.map +1 -0
  82. package/dist/protocols/output.js +81 -0
  83. package/dist/protocols/output.js.map +1 -0
  84. package/dist/protocols/responses.d.ts +12 -0
  85. package/dist/protocols/responses.d.ts.map +1 -0
  86. package/dist/protocols/responses.js +189 -0
  87. package/dist/protocols/responses.js.map +1 -0
  88. package/dist/protocols/types.d.ts +27 -0
  89. package/dist/protocols/types.d.ts.map +1 -0
  90. package/dist/protocols/types.js +2 -0
  91. package/dist/protocols/types.js.map +1 -0
  92. package/dist/protocols/usage.d.ts +3 -0
  93. package/dist/protocols/usage.d.ts.map +1 -0
  94. package/dist/protocols/usage.js +27 -0
  95. package/dist/protocols/usage.js.map +1 -0
  96. package/dist/protocols/values.d.ts +10 -0
  97. package/dist/protocols/values.d.ts.map +1 -0
  98. package/dist/protocols/values.js +32 -0
  99. package/dist/protocols/values.js.map +1 -0
  100. package/dist/test.d.ts +155 -0
  101. package/dist/test.d.ts.map +1 -0
  102. package/dist/test.js +8 -0
  103. package/dist/test.js.map +1 -0
  104. package/package.json +90 -0
  105. package/src/capture/index.ts +277 -0
  106. package/src/client.ts +286 -0
  107. package/src/component/_generated/api.ts +19 -0
  108. package/src/component/_generated/component.ts +32 -0
  109. package/src/component/_generated/dataModel.ts +15 -0
  110. package/src/component/_generated/server.ts +43 -0
  111. package/src/component/convex.config.ts +3 -0
  112. package/src/component/ingest.ts +328 -0
  113. package/src/component/queries.ts +248 -0
  114. package/src/component/schema.ts +73 -0
  115. package/src/content/index.ts +53 -0
  116. package/src/model-call/index.ts +224 -0
  117. package/src/projection.ts +99 -0
  118. package/src/protocols/anthropic-messages.ts +115 -0
  119. package/src/protocols/chat-completions.ts +94 -0
  120. package/src/protocols/checkpoint.ts +342 -0
  121. package/src/protocols/content.ts +41 -0
  122. package/src/protocols/framing.ts +62 -0
  123. package/src/protocols/index.ts +140 -0
  124. package/src/protocols/output.ts +78 -0
  125. package/src/protocols/responses.ts +188 -0
  126. package/src/protocols/types.ts +26 -0
  127. package/src/protocols/usage.ts +36 -0
  128. package/src/protocols/values.ts +35 -0
  129. package/src/test.ts +16 -0
@@ -0,0 +1,328 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "./_generated/server.js";
3
+ import { blobValue } from "./schema.js";
4
+ const bounded = (value: string, limit: number) => {
5
+ if (new TextEncoder().encode(value).byteLength > limit) throw new Error("component byte limit");
6
+ };
7
+ const identity = (value: string) => {
8
+ if (!/^[a-f0-9]{64}$/.test(value)) throw new Error("invalid identity");
9
+ };
10
+ export const admit = mutation({
11
+ args: {
12
+ destinationId: v.string(),
13
+ instanceId: v.string(),
14
+ pluginBootId: v.string(),
15
+ executionId: v.string(),
16
+ callId: v.string(),
17
+ sequence: v.number(),
18
+ throughSequence: v.number(),
19
+ terminalSequence: v.optional(v.number()),
20
+ identity: v.string(),
21
+ digest: v.string(),
22
+ reference: blobValue,
23
+ summaryJson: v.string(),
24
+ observedAt: v.string(),
25
+ correlation: v.object({
26
+ requestId: v.optional(v.string()),
27
+ runId: v.optional(v.string()),
28
+ jobId: v.optional(v.string()),
29
+ rootExecutionId: v.optional(v.string()),
30
+ opencodeSessionId: v.optional(v.string()),
31
+ }),
32
+ },
33
+ handler: async (ctx, args) => {
34
+ identity(args.identity);
35
+ identity(args.digest);
36
+ identity(args.callId);
37
+ bounded(args.summaryJson, 8 * 1024);
38
+ if (
39
+ args.reference.sha256 !== args.digest ||
40
+ args.reference.byteLength > 2 * 1028 * 1024 ||
41
+ args.reference.byteLength < 1 ||
42
+ !Number.isSafeInteger(args.sequence) ||
43
+ args.sequence < 1 ||
44
+ !Number.isSafeInteger(args.throughSequence) ||
45
+ args.throughSequence < args.sequence ||
46
+ args.throughSequence - args.sequence > 255
47
+ )
48
+ throw new Error("invalid segment");
49
+ bounded(JSON.stringify(args), 28 * 1024);
50
+ const receipt = await ctx.db
51
+ .query("receipts")
52
+ .withIndex("by_identity", (q) =>
53
+ q.eq("destinationId", args.destinationId).eq("identity", args.identity),
54
+ )
55
+ .unique();
56
+ if (receipt) {
57
+ if (receipt.digest !== args.digest) throw new Error("capture_digest_conflict");
58
+ return { identity: args.identity, digest: args.digest, callId: args.callId, duplicate: true };
59
+ }
60
+ const existingSequence = await ctx.db
61
+ .query("segments")
62
+ .withIndex("by_sequence", (q) =>
63
+ q
64
+ .eq("destinationId", args.destinationId)
65
+ .eq("callId", args.callId)
66
+ .eq("sequence", args.sequence),
67
+ )
68
+ .unique();
69
+ if (existingSequence) throw new Error("capture_sequence_conflict");
70
+ const previous = await ctx.db
71
+ .query("segments")
72
+ .withIndex("by_sequence", (q) =>
73
+ q
74
+ .eq("destinationId", args.destinationId)
75
+ .eq("callId", args.callId)
76
+ .lt("sequence", args.sequence),
77
+ )
78
+ .order("desc")
79
+ .first();
80
+ const next = await ctx.db
81
+ .query("segments")
82
+ .withIndex("by_sequence", (q) =>
83
+ q
84
+ .eq("destinationId", args.destinationId)
85
+ .eq("callId", args.callId)
86
+ .gt("sequence", args.sequence),
87
+ )
88
+ .order("asc")
89
+ .first();
90
+ if (
91
+ (previous && previous.throughSequence >= args.sequence) ||
92
+ (next && next.sequence <= args.throughSequence)
93
+ )
94
+ throw new Error("capture_sequence_conflict");
95
+ const now = Date.now();
96
+ let call = await ctx.db
97
+ .query("calls")
98
+ .withIndex("by_call", (q) =>
99
+ q.eq("destinationId", args.destinationId).eq("callId", args.callId),
100
+ )
101
+ .unique();
102
+ if (!call) {
103
+ const document = await ctx.db.insert("calls", {
104
+ destinationId: args.destinationId,
105
+ callId: args.callId,
106
+ instanceId: args.instanceId,
107
+ pluginBootId: args.pluginBootId,
108
+ executionId: args.executionId,
109
+ receivedAt: now,
110
+ revision: 0,
111
+ projectedThroughSequence: 0,
112
+ persistedThroughSequence: 0,
113
+ summaryJson: args.summaryJson,
114
+ checkpointJson: "{}",
115
+ ...args.correlation,
116
+ });
117
+ call = await ctx.db.get("calls", document);
118
+ }
119
+ if (!call) throw new Error("call missing");
120
+ if (
121
+ call.instanceId !== args.instanceId ||
122
+ call.pluginBootId !== args.pluginBootId ||
123
+ call.executionId !== args.executionId ||
124
+ (call.terminalSequence !== undefined && args.throughSequence > call.terminalSequence)
125
+ )
126
+ throw new Error("capture_sequence_conflict");
127
+ if (args.sequence === 1) await ctx.db.patch("calls", call._id, args.correlation);
128
+ await ctx.db.insert("segments", {
129
+ destinationId: args.destinationId,
130
+ callId: args.callId,
131
+ sequence: args.sequence,
132
+ throughSequence: args.throughSequence,
133
+ identity: args.identity,
134
+ digest: args.digest,
135
+ reference: args.reference,
136
+ receivedAt: now,
137
+ });
138
+ await ctx.db.insert("receipts", {
139
+ destinationId: args.destinationId,
140
+ identity: args.identity,
141
+ digest: args.digest,
142
+ callId: args.callId,
143
+ sequence: args.sequence,
144
+ receivedAt: now,
145
+ });
146
+ // Bounded contiguous watermark advance. Projection workers continue it in bounded pages.
147
+ const pending = await ctx.db
148
+ .query("segments")
149
+ .withIndex("by_sequence", (q) =>
150
+ q
151
+ .eq("destinationId", args.destinationId)
152
+ .eq("callId", args.callId)
153
+ .gt("sequence", call.persistedThroughSequence),
154
+ )
155
+ .take(8);
156
+ let through = call.persistedThroughSequence;
157
+ for (const segment of pending) {
158
+ if (segment.sequence !== through + 1) break;
159
+ through = segment.throughSequence;
160
+ }
161
+ const patch: { persistedThroughSequence: number; terminalSequence?: number } = {
162
+ persistedThroughSequence: through,
163
+ };
164
+ if (args.terminalSequence !== undefined) patch.terminalSequence = args.terminalSequence;
165
+ await ctx.db.patch("calls", call._id, patch);
166
+ const status = await ctx.db
167
+ .query("sourceStatus")
168
+ .withIndex("by_source", (q) =>
169
+ q.eq("destinationId", args.destinationId).eq("instanceId", args.instanceId),
170
+ )
171
+ .unique();
172
+ const health = {
173
+ destinationId: args.destinationId,
174
+ instanceId: args.instanceId,
175
+ pluginBootId: args.pluginBootId,
176
+ lastObservedAt: args.observedAt,
177
+ lastReceivedAt: now,
178
+ coverage: "precommit_and_pre_hook_unavailable",
179
+ healthJson: status?.pluginBootId === args.pluginBootId ? status.healthJson : undefined,
180
+ };
181
+ if (status && Date.parse(status.lastObservedAt) <= Date.parse(args.observedAt))
182
+ await ctx.db.patch("sourceStatus", status._id, health);
183
+ else if (!status) await ctx.db.insert("sourceStatus", health);
184
+ return { identity: args.identity, digest: args.digest, callId: args.callId, duplicate: false };
185
+ },
186
+ });
187
+ export const commitProjection = mutation({
188
+ args: {
189
+ destinationId: v.string(),
190
+ callId: v.string(),
191
+ expectedRevision: v.number(),
192
+ throughSequence: v.number(),
193
+ summaryJson: v.string(),
194
+ checkpointJson: v.string(),
195
+ },
196
+ handler: async (ctx, args) => {
197
+ bounded(args.summaryJson, 8 * 1024);
198
+ bounded(args.checkpointJson, 68 * 1024);
199
+ const call = await ctx.db
200
+ .query("calls")
201
+ .withIndex("by_call", (q) =>
202
+ q.eq("destinationId", args.destinationId).eq("callId", args.callId),
203
+ )
204
+ .unique();
205
+ if (!call) throw new Error("call missing");
206
+ if (call.revision !== args.expectedRevision)
207
+ return { committed: false, revision: call.revision };
208
+ if (
209
+ args.throughSequence < call.projectedThroughSequence ||
210
+ args.throughSequence > call.projectedThroughSequence + 8 * 256
211
+ )
212
+ throw new Error("projection range limit");
213
+ const rows = await ctx.db
214
+ .query("segments")
215
+ .withIndex("by_sequence", (q) =>
216
+ q
217
+ .eq("destinationId", args.destinationId)
218
+ .eq("callId", args.callId)
219
+ .gt("sequence", call.projectedThroughSequence),
220
+ )
221
+ .take(8);
222
+ let through = call.projectedThroughSequence;
223
+ for (const row of rows) {
224
+ if (row.sequence !== through + 1 || row.throughSequence > args.throughSequence) break;
225
+ through = row.throughSequence;
226
+ }
227
+ if (through !== args.throughSequence) throw new Error("projection crosses gap");
228
+ await ctx.db.patch("calls", call._id, {
229
+ revision: call.revision + 1,
230
+ projectedThroughSequence: through,
231
+ persistedThroughSequence: Math.max(call.persistedThroughSequence, through),
232
+ summaryJson: args.summaryJson,
233
+ checkpointJson: args.checkpointJson,
234
+ projectionFailure: undefined,
235
+ });
236
+ return { committed: true, revision: call.revision + 1 };
237
+ },
238
+ });
239
+
240
+ export const recordProjectionFailure = mutation({
241
+ args: { destinationId: v.string(), callId: v.string(), expectedRevision: v.number() },
242
+ handler: async (ctx, args) => {
243
+ const call = await ctx.db
244
+ .query("calls")
245
+ .withIndex("by_call", (q) =>
246
+ q.eq("destinationId", args.destinationId).eq("callId", args.callId),
247
+ )
248
+ .unique();
249
+ if (call && call.revision === args.expectedRevision)
250
+ await ctx.db.patch("calls", call._id, {
251
+ projectionFailure: { reason: "projection_processing_failed", at: Date.now() },
252
+ });
253
+ },
254
+ });
255
+
256
+ export const recordHealth = mutation({
257
+ args: {
258
+ destinationId: v.string(),
259
+ instanceId: v.string(),
260
+ pluginBootId: v.string(),
261
+ startedAt: v.string(),
262
+ observedAt: v.string(),
263
+ observationsTotal: v.number(),
264
+ droppedObservationsTotal: v.number(),
265
+ lostControlObservationsTotal: v.number(),
266
+ scopeConflictsTotal: v.number(),
267
+ expiredScopesTotal: v.number(),
268
+ activeCalls: v.number(),
269
+ },
270
+ handler: async (ctx, args) => {
271
+ bounded(JSON.stringify(args), 4096);
272
+ const at = Date.parse(args.observedAt);
273
+ if (!Number.isFinite(at) || !Number.isFinite(Date.parse(args.startedAt)))
274
+ throw new Error("invalid health time");
275
+ for (const value of [
276
+ args.observationsTotal,
277
+ args.droppedObservationsTotal,
278
+ args.lostControlObservationsTotal,
279
+ args.scopeConflictsTotal,
280
+ args.expiredScopesTotal,
281
+ args.activeCalls,
282
+ ])
283
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid health counter");
284
+ const healthJson = JSON.stringify({
285
+ ...args,
286
+ counterScope: "plugin_boot",
287
+ precommitCoverage: "unknown_before_local_commit",
288
+ });
289
+ const boot = await ctx.db
290
+ .query("bootHealth")
291
+ .withIndex("by_boot", (q) =>
292
+ q
293
+ .eq("destinationId", args.destinationId)
294
+ .eq("instanceId", args.instanceId)
295
+ .eq("pluginBootId", args.pluginBootId),
296
+ )
297
+ .unique();
298
+ if (!boot)
299
+ await ctx.db.insert("bootHealth", {
300
+ destinationId: args.destinationId,
301
+ instanceId: args.instanceId,
302
+ pluginBootId: args.pluginBootId,
303
+ observedAt: at,
304
+ healthJson,
305
+ });
306
+ else if (at >= boot.observedAt)
307
+ await ctx.db.patch("bootHealth", boot._id, { observedAt: at, healthJson });
308
+ const status = await ctx.db
309
+ .query("sourceStatus")
310
+ .withIndex("by_source", (q) =>
311
+ q.eq("destinationId", args.destinationId).eq("instanceId", args.instanceId),
312
+ )
313
+ .unique();
314
+ const row = {
315
+ destinationId: args.destinationId,
316
+ instanceId: args.instanceId,
317
+ pluginBootId: args.pluginBootId,
318
+ lastObservedAt: args.observedAt,
319
+ lastReceivedAt: Date.now(),
320
+ coverage: "precommit_and_pre_hook_unavailable",
321
+ healthJson,
322
+ };
323
+ if (!status) await ctx.db.insert("sourceStatus", row);
324
+ else if (at >= Date.parse(status.lastObservedAt))
325
+ await ctx.db.patch("sourceStatus", status._id, row);
326
+ return { committed: true };
327
+ },
328
+ });
@@ -0,0 +1,248 @@
1
+ import type { ModelCallV1 } from "../model-call/index.js";
2
+ import { v } from "convex/values";
3
+ import { query, type QueryCtx } from "./_generated/server.js";
4
+ async function summaryView(
5
+ ctx: QueryCtx,
6
+ row: {
7
+ summaryJson: string;
8
+ destinationId: string;
9
+ instanceId: string;
10
+ pluginBootId: string;
11
+ terminalSequence?: number;
12
+ },
13
+ ) {
14
+ const summary = JSON.parse(row.summaryJson) as ModelCallV1;
15
+ if (summary.state === "in_progress" && row.terminalSequence === undefined) {
16
+ const source = await ctx.db
17
+ .query("sourceStatus")
18
+ .withIndex("by_source", (q) =>
19
+ q.eq("destinationId", row.destinationId).eq("instanceId", row.instanceId),
20
+ )
21
+ .unique();
22
+ if (source?.healthJson && source.pluginBootId !== row.pluginBootId) {
23
+ summary.state = "unknown";
24
+ summary.capture.raw = "partial";
25
+ summary.capture.gaps = [...summary.capture.gaps, "completion_unobserved_prior_boot"].slice(
26
+ -8,
27
+ );
28
+ }
29
+ }
30
+ return summary;
31
+ }
32
+ const owner = { destinationId: v.string(), callId: v.string() };
33
+ export const getCall = query({
34
+ args: owner,
35
+ handler: async (ctx, args) => {
36
+ const call = await ctx.db
37
+ .query("calls")
38
+ .withIndex("by_call", (q) =>
39
+ q.eq("destinationId", args.destinationId).eq("callId", args.callId),
40
+ )
41
+ .unique();
42
+ if (!call) return null;
43
+ return {
44
+ ...call,
45
+ summaryJson: JSON.stringify(await summaryView(ctx, call)),
46
+ checkpointJson: undefined,
47
+ };
48
+ },
49
+ });
50
+ export const getProcessingState = query({
51
+ args: owner,
52
+ handler: async (ctx, args) =>
53
+ ctx.db
54
+ .query("calls")
55
+ .withIndex("by_call", (q) =>
56
+ q.eq("destinationId", args.destinationId).eq("callId", args.callId),
57
+ )
58
+ .unique(),
59
+ });
60
+ export const pageEventSegments = query({
61
+ args: { ...owner, afterSequence: v.optional(v.number()), limit: v.optional(v.number()) },
62
+ handler: async (ctx, args) => {
63
+ const limit = Math.max(1, Math.min(8, Math.floor(args.limit ?? 7)));
64
+ const rows = await ctx.db
65
+ .query("segments")
66
+ .withIndex("by_sequence", (q) =>
67
+ q
68
+ .eq("destinationId", args.destinationId)
69
+ .eq("callId", args.callId)
70
+ .gt("sequence", args.afterSequence ?? 0),
71
+ )
72
+ .take(limit + 1);
73
+ return {
74
+ segments: rows.slice(0, limit),
75
+ cursor: rows.length
76
+ ? rows[Math.min(rows.length, limit) - 1]!.throughSequence
77
+ : (args.afterSequence ?? 0),
78
+ done: rows.length <= limit,
79
+ };
80
+ },
81
+ });
82
+ const cursor = v.object({ receivedAt: v.number(), creationTime: v.number() });
83
+ export const pageRecentSummaries = query({
84
+ args: {
85
+ destinationId: v.string(),
86
+ cursor: v.optional(cursor),
87
+ limit: v.optional(v.number()),
88
+ correlation: v.optional(
89
+ v.object({
90
+ kind: v.union(
91
+ v.literal("requestId"),
92
+ v.literal("runId"),
93
+ v.literal("jobId"),
94
+ v.literal("rootExecutionId"),
95
+ v.literal("opencodeSessionId"),
96
+ ),
97
+ value: v.string(),
98
+ }),
99
+ ),
100
+ },
101
+ handler: async (ctx, args) => {
102
+ const limit = Math.max(1, Math.min(3, Math.floor(args.limit ?? 3)));
103
+ const correlation = args.correlation;
104
+ const c = args.cursor;
105
+ async function read(mode: "first" | "tie" | "older", take: number) {
106
+ if (correlation?.kind === "requestId")
107
+ return ctx.db
108
+ .query("calls")
109
+ .withIndex("by_request", (q) => {
110
+ const base = q
111
+ .eq("destinationId", args.destinationId)
112
+ .eq("requestId", correlation.value);
113
+ return !c
114
+ ? base
115
+ : mode === "tie"
116
+ ? base.eq("receivedAt", c.receivedAt).lt("_creationTime", c.creationTime)
117
+ : base.lt("receivedAt", c.receivedAt);
118
+ })
119
+ .order("desc")
120
+ .take(take);
121
+ if (correlation?.kind === "runId")
122
+ return ctx.db
123
+ .query("calls")
124
+ .withIndex("by_run", (q) => {
125
+ const base = q.eq("destinationId", args.destinationId).eq("runId", correlation.value);
126
+ return !c
127
+ ? base
128
+ : mode === "tie"
129
+ ? base.eq("receivedAt", c.receivedAt).lt("_creationTime", c.creationTime)
130
+ : base.lt("receivedAt", c.receivedAt);
131
+ })
132
+ .order("desc")
133
+ .take(take);
134
+ if (correlation?.kind === "jobId")
135
+ return ctx.db
136
+ .query("calls")
137
+ .withIndex("by_job", (q) => {
138
+ const base = q.eq("destinationId", args.destinationId).eq("jobId", correlation.value);
139
+ return !c
140
+ ? base
141
+ : mode === "tie"
142
+ ? base.eq("receivedAt", c.receivedAt).lt("_creationTime", c.creationTime)
143
+ : base.lt("receivedAt", c.receivedAt);
144
+ })
145
+ .order("desc")
146
+ .take(take);
147
+ if (correlation?.kind === "rootExecutionId")
148
+ return ctx.db
149
+ .query("calls")
150
+ .withIndex("by_root_execution", (q) => {
151
+ const base = q
152
+ .eq("destinationId", args.destinationId)
153
+ .eq("rootExecutionId", correlation.value);
154
+ return !c
155
+ ? base
156
+ : mode === "tie"
157
+ ? base.eq("receivedAt", c.receivedAt).lt("_creationTime", c.creationTime)
158
+ : base.lt("receivedAt", c.receivedAt);
159
+ })
160
+ .order("desc")
161
+ .take(take);
162
+ if (correlation?.kind === "opencodeSessionId")
163
+ return ctx.db
164
+ .query("calls")
165
+ .withIndex("by_opencode_session", (q) => {
166
+ const base = q
167
+ .eq("destinationId", args.destinationId)
168
+ .eq("opencodeSessionId", correlation.value);
169
+ return !c
170
+ ? base
171
+ : mode === "tie"
172
+ ? base.eq("receivedAt", c.receivedAt).lt("_creationTime", c.creationTime)
173
+ : base.lt("receivedAt", c.receivedAt);
174
+ })
175
+ .order("desc")
176
+ .take(take);
177
+ return ctx.db
178
+ .query("calls")
179
+ .withIndex("by_received", (q) => {
180
+ const base = q.eq("destinationId", args.destinationId);
181
+ return !c
182
+ ? base
183
+ : mode === "tie"
184
+ ? base.eq("receivedAt", c.receivedAt).lt("_creationTime", c.creationTime)
185
+ : base.lt("receivedAt", c.receivedAt);
186
+ })
187
+ .order("desc")
188
+ .take(take);
189
+ }
190
+ const rows = c ? await read("tie", limit + 1) : await read("first", limit + 1);
191
+ if (c && rows.length < limit + 1) rows.push(...(await read("older", limit + 1 - rows.length)));
192
+ const calls: ModelCallV1[] = [];
193
+ let bytes = 256;
194
+ let next = args.cursor;
195
+ let consumed = 0;
196
+ for (const row of rows.slice(0, limit)) {
197
+ const view = await summaryView(ctx, row);
198
+ const summary = {
199
+ ...view,
200
+ sourceDocumentId: row._id,
201
+ receivedAt: row.receivedAt,
202
+ capture: {
203
+ ...view.capture,
204
+ projectedThroughSequence: row.projectedThroughSequence,
205
+ persistedThroughSequence: row.persistedThroughSequence,
206
+ },
207
+ };
208
+ const length = new TextEncoder().encode(JSON.stringify(summary)).byteLength;
209
+ if (bytes + length > 31 * 1024) break;
210
+ calls.push(summary);
211
+ bytes += length;
212
+ consumed++;
213
+ next = { receivedAt: row.receivedAt, creationTime: row._creationTime };
214
+ }
215
+ return { calls, cursor: next ?? null, done: consumed === rows.length };
216
+ },
217
+ });
218
+ export const getCaptureCoverage = query({
219
+ args: { destinationId: v.string() },
220
+ handler: async (ctx, args) => {
221
+ const sources = await ctx.db
222
+ .query("sourceStatus")
223
+ .withIndex("by_source", (q) => q.eq("destinationId", args.destinationId))
224
+ .take(17);
225
+ return {
226
+ retention: "indefinite" as const,
227
+ preHook: "unavailable" as const,
228
+ previews: "not_enrolled" as const,
229
+ attemptDetail: "unavailable" as const,
230
+ sources: sources.slice(0, 16),
231
+ sourcesComplete: sources.length <= 16,
232
+ };
233
+ },
234
+ });
235
+
236
+ export const getBootHealth = query({
237
+ args: { destinationId: v.string(), instanceId: v.string(), pluginBootId: v.string() },
238
+ handler: async (ctx, args) =>
239
+ ctx.db
240
+ .query("bootHealth")
241
+ .withIndex("by_boot", (q) =>
242
+ q
243
+ .eq("destinationId", args.destinationId)
244
+ .eq("instanceId", args.instanceId)
245
+ .eq("pluginBootId", args.pluginBootId),
246
+ )
247
+ .unique(),
248
+ });
@@ -0,0 +1,73 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ export const blobValue = v.object({
4
+ key: v.string(),
5
+ sha256: v.string(),
6
+ byteLength: v.number(),
7
+ contentType: v.string(),
8
+ });
9
+ export default defineSchema({
10
+ calls: defineTable({
11
+ destinationId: v.string(),
12
+ callId: v.string(),
13
+ instanceId: v.string(),
14
+ pluginBootId: v.string(),
15
+ executionId: v.string(),
16
+ receivedAt: v.number(),
17
+ revision: v.number(),
18
+ projectedThroughSequence: v.number(),
19
+ persistedThroughSequence: v.number(),
20
+ terminalSequence: v.optional(v.number()),
21
+ summaryJson: v.string(),
22
+ checkpointJson: v.string(),
23
+ projectionFailure: v.optional(v.object({ reason: v.string(), at: v.number() })),
24
+ requestId: v.optional(v.string()),
25
+ runId: v.optional(v.string()),
26
+ jobId: v.optional(v.string()),
27
+ rootExecutionId: v.optional(v.string()),
28
+ opencodeSessionId: v.optional(v.string()),
29
+ })
30
+ .index("by_call", ["destinationId", "callId"])
31
+ .index("by_received", ["destinationId", "receivedAt"])
32
+ .index("by_request", ["destinationId", "requestId", "receivedAt"])
33
+ .index("by_run", ["destinationId", "runId", "receivedAt"])
34
+ .index("by_job", ["destinationId", "jobId", "receivedAt"])
35
+ .index("by_root_execution", ["destinationId", "rootExecutionId", "receivedAt"])
36
+ .index("by_opencode_session", ["destinationId", "opencodeSessionId", "receivedAt"]),
37
+ segments: defineTable({
38
+ destinationId: v.string(),
39
+ callId: v.string(),
40
+ sequence: v.number(),
41
+ throughSequence: v.number(),
42
+ identity: v.string(),
43
+ digest: v.string(),
44
+ reference: blobValue,
45
+ receivedAt: v.number(),
46
+ })
47
+ .index("by_identity", ["destinationId", "identity"])
48
+ .index("by_sequence", ["destinationId", "callId", "sequence"]),
49
+ receipts: defineTable({
50
+ destinationId: v.string(),
51
+ identity: v.string(),
52
+ digest: v.string(),
53
+ callId: v.string(),
54
+ sequence: v.number(),
55
+ receivedAt: v.number(),
56
+ }).index("by_identity", ["destinationId", "identity"]),
57
+ bootHealth: defineTable({
58
+ destinationId: v.string(),
59
+ instanceId: v.string(),
60
+ pluginBootId: v.string(),
61
+ observedAt: v.number(),
62
+ healthJson: v.string(),
63
+ }).index("by_boot", ["destinationId", "instanceId", "pluginBootId"]),
64
+ sourceStatus: defineTable({
65
+ destinationId: v.string(),
66
+ instanceId: v.string(),
67
+ pluginBootId: v.string(),
68
+ lastObservedAt: v.string(),
69
+ lastReceivedAt: v.number(),
70
+ coverage: v.string(),
71
+ healthJson: v.optional(v.string()),
72
+ }).index("by_source", ["destinationId", "instanceId"]),
73
+ });
@@ -0,0 +1,53 @@
1
+ import { sha256 } from "../capture/index.js";
2
+ import type { PrivateContentReference } from "../model-call/index.js";
3
+ export type PrivateCaptureStorage = {
4
+ put: (reference: PrivateContentReference, bytes: Uint8Array) => Promise<void>;
5
+ get: (key: string) => Promise<ReadableStream<Uint8Array>>;
6
+ };
7
+ /** The host must obtain manifest from the authorized selected call, never from model content. */
8
+ export async function resolveCallBlob(
9
+ storage: Pick<PrivateCaptureStorage, "get">,
10
+ manifest: readonly PrivateContentReference[],
11
+ selected: PrivateContentReference,
12
+ maxBytes = 2 * 1024 * 1024,
13
+ ): Promise<Uint8Array> {
14
+ if (
15
+ !manifest.some(
16
+ (ref) =>
17
+ ref.key === selected.key &&
18
+ ref.sha256 === selected.sha256 &&
19
+ ref.byteLength === selected.byteLength &&
20
+ ref.contentType === selected.contentType,
21
+ ) ||
22
+ !Number.isSafeInteger(selected.byteLength) ||
23
+ selected.byteLength < 0 ||
24
+ selected.byteLength > maxBytes
25
+ )
26
+ throw new Error("unowned or oversized content reference");
27
+ const reader = (await storage.get(selected.key)).getReader();
28
+ const chunks: Uint8Array[] = [];
29
+ let size = 0;
30
+ try {
31
+ while (true) {
32
+ const { done, value } = await reader.read();
33
+ if (done) break;
34
+ size += value.byteLength;
35
+ if (size > selected.byteLength || size > maxBytes) {
36
+ await reader.cancel();
37
+ throw new Error("stored content bound exceeded");
38
+ }
39
+ chunks.push(value);
40
+ }
41
+ } finally {
42
+ reader.releaseLock();
43
+ }
44
+ const body = new Uint8Array(size);
45
+ let offset = 0;
46
+ for (const chunk of chunks) {
47
+ body.set(chunk, offset);
48
+ offset += chunk.byteLength;
49
+ }
50
+ if (size !== selected.byteLength || (await sha256(body)) !== selected.sha256)
51
+ throw new Error("stored content integrity failure");
52
+ return body;
53
+ }