@boxxy/pepys 0.1.3 → 0.1.5

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.
@@ -0,0 +1,228 @@
1
+ import { isDeliveryAuthentic } from '@pepys/kit';
2
+ import type { DeliveryCheck, FactEnvelope } from '@pepys/kit';
3
+ import { createTestStore } from '@pepys/testing';
4
+ import { z } from 'zod';
5
+ import { boxxySource } from '../boxxy-source.ts';
6
+ import { buildBoxxyPlugin } from '../build-boxxy-plugin.ts';
7
+ import { buildCostToLandQuery } from '../queries/build-cost-to-land-query.ts';
8
+ import { buildStageTimeQuery } from '../queries/build-stage-time-query.ts';
9
+ import type { DeliveryReads, ForeignReads } from '../queries/types.ts';
10
+ import { isOutgoingFact } from '../sink/is-outgoing-fact.ts';
11
+ import type { UncheckedQueryClient } from '../types.ts';
12
+ import { buildForeignStandIn } from './build-foreign-stand-in.ts';
13
+ import { toEntityID } from './to-entity-id.ts';
14
+
15
+ export interface TestPepysOptions {
16
+ // the value of `PEPYS_BOXXY_SIGNING_SECRET`, which the fact batch route checks
17
+ readonly boxxySecret: string;
18
+
19
+ // the value of `PEPYS_GITHUB_WEBHOOK_SECRET`, which the webhook route checks
20
+ readonly webhookSecret: string;
21
+ }
22
+
23
+ // One model request of an agent session, with the proven sender of its record.
24
+ export interface TelemetryRequest {
25
+ readonly id: string;
26
+
27
+ // the atc session ID that the agent copies onto its records
28
+ readonly session: string;
29
+ readonly origin: { readonly issuer: string; readonly subject: string };
30
+ readonly model: string;
31
+ readonly costUSD: number;
32
+ readonly inputTokens: number;
33
+ readonly outputTokens: number;
34
+
35
+ // ISO 8601
36
+ readonly at: string;
37
+ }
38
+
39
+ export interface TestPepys extends AsyncDisposable {
40
+ // ingest's two routes that boxxy's journey reaches: the boxxy fact batch route and the GitHub
41
+ // webhook route; any other path answers 404
42
+ readonly handleRequest: (request: Request) => Promise<Response>;
43
+
44
+ // the two queries of the boxxy plug-in, as the pepys API answers them
45
+ readonly client: UncheckedQueryClient;
46
+
47
+ // the rows that the telemetry route would write for these records
48
+ readonly writeTelemetry: (requests: readonly TelemetryRequest[]) => Promise<void>;
49
+ }
50
+
51
+ const webhookRoute = '/webhook/github';
52
+
53
+ const webhookCheck: DeliveryCheck = {
54
+ kind: 'hmac-sha256',
55
+ header: 'x-hub-signature-256',
56
+ secretEnv: 'PEPYS_GITHUB_WEBHOOK_SECRET',
57
+ };
58
+
59
+ // GitHub's `pull_request` event, as far as the merge time goes
60
+ const pullRequestEventSchema = z.object({
61
+ action: z.string(),
62
+ pull_request: z.object({ id: z.number().int(), merged_at: z.string().nullable() }),
63
+ });
64
+
65
+ // The boxxy plug-in on the kit's test store, behind the kit's own signature check and batch read.
66
+ // The GitHub and telemetry plug-ins are stand-ins: the webhook writes the pull request row, and
67
+ // the delivery read knows merges only, no release and no deploy.
68
+ export async function createTestPepys(options: TestPepysOptions): Promise<TestPepys> {
69
+ const standIn = buildForeignStandIn();
70
+
71
+ const merges = new Map<string, string | null>();
72
+
73
+ const foreign: ForeignReads = { ...standIn.foreign, delivery: buildDeliveryReads(merges) };
74
+ const plugin = buildBoxxyPlugin(foreign);
75
+
76
+ const store = await createTestStore({ plugins: [plugin, ...standIn.plugins] });
77
+
78
+ const costQuery = buildCostToLandQuery(foreign);
79
+ const stageQuery = buildStageTimeQuery(foreign.delivery);
80
+ const path = boxxySource.live;
81
+
82
+ if (path?.kind !== 'receive') {
83
+ throw new Error('the boxxy source has no receive path');
84
+ }
85
+
86
+ const stored = new Set<string>();
87
+
88
+ const receiveFacts = async (request: Request): Promise<Response> => {
89
+ const buffer = await request.arrayBuffer();
90
+
91
+ const body = new Uint8Array(buffer);
92
+
93
+ if (!isDeliveryAuthentic(path.check, options.boxxySecret, request.headers, body)) {
94
+ return Response.json({ error: 'the signature does not match' }, { status: 401 });
95
+ }
96
+
97
+ const received = path.read(
98
+ { headers: request.headers, body },
99
+ { name: boxxySource.name, families: boxxySource.families },
100
+ );
101
+
102
+ if (received.kind === 'refused') {
103
+ return Response.json({ error: received.reason }, { status: received.status });
104
+ }
105
+
106
+ if (received.kind === 'ignored') {
107
+ return Response.json({ status: 'ignored' }, { status: 202 });
108
+ }
109
+
110
+ const facts = received.batch.facts
111
+ .filter((pending) => !stored.has(pending.fact.id))
112
+ .map((pending) => toOutgoingFact(pending.fact))
113
+ .filter((fact) => isOutgoingFact(fact));
114
+
115
+ await store.apply(boxxySource, facts);
116
+
117
+ for (const pending of received.batch.facts) {
118
+ stored.add(pending.fact.id);
119
+ }
120
+
121
+ return Response.json({ status: 'stored', stored: received.batch.facts.length });
122
+ };
123
+
124
+ const receiveWebhook = async (request: Request): Promise<Response> => {
125
+ const buffer = await request.arrayBuffer();
126
+
127
+ const body = new Uint8Array(buffer);
128
+
129
+ if (!isDeliveryAuthentic(webhookCheck, options.webhookSecret, request.headers, body)) {
130
+ return Response.json({ error: 'the signature does not match' }, { status: 401 });
131
+ }
132
+
133
+ const raw: unknown = JSON.parse(new TextDecoder().decode(body));
134
+ const event = pullRequestEventSchema.safeParse(raw);
135
+
136
+ if (request.headers.get('x-github-event') !== 'pull_request' || !event.success) {
137
+ return Response.json({ status: 'ignored' }, { status: 202 });
138
+ }
139
+
140
+ const id = `github:pull_request:${event.data.pull_request.id}`;
141
+
142
+ merges.set(id, event.data.pull_request.merged_at);
143
+
144
+ await store.writeRows(standIn.consumers.githubPullRequest, 'github_pull_request', [
145
+ { id: toEntityID(id), merged_at: event.data.pull_request.merged_at },
146
+ ]);
147
+
148
+ return Response.json({ status: 'stored', stored: 1 });
149
+ };
150
+
151
+ return {
152
+ handleRequest: (request) => {
153
+ const route = new URL(request.url).pathname;
154
+
155
+ if (route === path.route) {
156
+ return receiveFacts(request);
157
+ }
158
+
159
+ return route === webhookRoute
160
+ ? receiveWebhook(request)
161
+ : Promise.resolve(Response.json({ error: 'no such route' }, { status: 404 }));
162
+ },
163
+ client: {
164
+ boxxy_cost_to_land: (input) => store.query(costQuery, input),
165
+ boxxy_stage_time: (input) => store.query(stageQuery, input),
166
+ },
167
+ writeTelemetry: (requests) =>
168
+ store.writeRows(
169
+ standIn.consumers.telemetryModelRequest,
170
+ 'telemetry_model_request',
171
+ requests.map((request) => ({
172
+ id: toEntityID(`telemetry:model_request:${request.id}`),
173
+ session_id: toEntityID(`telemetry:session:${request.session}`),
174
+ origin_issuer: request.origin.issuer,
175
+ origin_subject: request.origin.subject,
176
+ product: 'claude_code',
177
+ model: request.model,
178
+ cost_basis: 'reported',
179
+ cost_usd: request.costUSD,
180
+ input_tokens: request.inputTokens,
181
+ cache_read_tokens: 0,
182
+ cache_creation_tokens: 0,
183
+ output_tokens: request.outputTokens,
184
+ at: request.at,
185
+ })),
186
+ ),
187
+ [Symbol.asyncDispose]: () => store[Symbol.asyncDispose](),
188
+ };
189
+ }
190
+
191
+ // The merge of each pull request that an attempt of the item produced, read through the
192
+ // `produced` links as the GitHub plug-in's delivery module reads it.
193
+ function buildDeliveryReads(merges: ReadonlyMap<string, string | null>): DeliveryReads {
194
+ return {
195
+ consumers: [],
196
+ readDeliveries: async (db, input) => {
197
+ const produced = await db
198
+ .selectFrom('link_effective as produced')
199
+ .innerJoin('boxxy_attempt as attempt', 'attempt.id', 'produced.from_id')
200
+ .where('produced.type', '=', 'produced')
201
+ .where('attempt.item_id', '=', input.item)
202
+ .select('produced.to_id as id')
203
+ .distinct()
204
+ .orderBy('produced.to_id')
205
+ .execute();
206
+
207
+ return produced.map((row) => ({
208
+ pullRequest: row.id,
209
+ mergedAt: merges.get(row.id) ?? null,
210
+ release: null,
211
+ deploy: null,
212
+ }));
213
+ },
214
+ };
215
+ }
216
+
217
+ // The fact of a batch that ingest built the envelope from; the event is the envelope's ID less
218
+ // the source's prefix.
219
+ function toOutgoingFact(fact: FactEnvelope<unknown>) {
220
+ return {
221
+ type: fact.type,
222
+ event: fact.id.replace(`event:${boxxySource.name}:`, ''),
223
+ entity: fact.entity,
224
+ ...(fact.actor === undefined ? {} : { actor: fact.actor }),
225
+ at: fact.at,
226
+ payload: fact.payload,
227
+ };
228
+ }
@@ -0,0 +1,14 @@
1
+ import { buildEntityID, parseEntityID } from '@pepys/kit';
2
+ import type { EntityID } from '@pepys/kit';
3
+
4
+ // A row that a test writes takes its entity ID columns as the kit's entity ID type.
5
+ export function toEntityID(text: string): EntityID {
6
+ const parsed = parseEntityID(text);
7
+ const built = parsed.ok ? buildEntityID(parsed.value) : parsed;
8
+
9
+ if (!built.ok) {
10
+ throw new Error(`${text} is no entity ID: ${built.failure.reason}`);
11
+ }
12
+
13
+ return built.value;
14
+ }
@@ -0,0 +1,92 @@
1
+ import type { OutgoingFact } from '@pepys/kit';
2
+ import { z } from 'zod';
3
+ import type { boxxySource } from '../boxxy-source.ts';
4
+ import type { WorkFact } from '../work/types.ts';
5
+
6
+ const base = { id: z.string(), entity: z.string(), at: z.string() };
7
+ const ofAttempt = { attempt: z.string() };
8
+ const callerSchema = z.object({ issuer: z.string(), subject: z.string() }).nullable();
9
+
10
+ const filedPayload = z.object({
11
+ kind: z.string(),
12
+ repo: z.string(),
13
+ title: z.string(),
14
+ fields: z.object({ designMode: z.unknown().optional() }),
15
+ });
16
+
17
+ const editedPayload = z.object({
18
+ title: z.unknown().optional(),
19
+ kind: z.unknown().optional(),
20
+ fields: z.unknown().optional(),
21
+ });
22
+
23
+ const closedPayload = z.object({ reason: z.string() });
24
+
25
+ const claimedPayload = z.object({
26
+ ...ofAttempt,
27
+ phase: z.string(),
28
+ number: z.number(),
29
+ round: z.number(),
30
+ mode: z.string(),
31
+ pool: z.string(),
32
+ recipeHash: z.string(),
33
+ rulesVersion: z.string(),
34
+ });
35
+
36
+ const sessionPayload = z.object({ ...ofAttempt, atcSession: z.string(), caller: callerSchema });
37
+
38
+ const boundPayload = z.object({
39
+ ...ofAttempt,
40
+ atcSession: z.string(),
41
+ agentSession: z.string(),
42
+ product: z.string(),
43
+ });
44
+
45
+ const settledPayload = z.object({
46
+ ...ofAttempt,
47
+ state: z.string(),
48
+ outcome: z.string(),
49
+ failure: z.string().nullable(),
50
+ asleep: z.boolean(),
51
+ });
52
+
53
+ const linkedPayload = z.object({ ...ofAttempt, pullRequestId: z.string() });
54
+
55
+ const sessionTypes = z.enum([
56
+ 'boxxy.attempt.started',
57
+ 'boxxy.attempt.reconciled',
58
+ 'boxxy.attempt.resumed',
59
+ ]);
60
+
61
+ const markPayload = z.object(ofAttempt);
62
+
63
+ const markTypes = z.enum([
64
+ 'boxxy.attempt.slept',
65
+ 'boxxy.attempt.woken',
66
+ 'boxxy.attempt.closed_out',
67
+ ]);
68
+
69
+ const scopeFactSchema: z.ZodType<WorkFact> = z.discriminatedUnion('type', [
70
+ z.object({ ...base, type: z.literal('boxxy.item.filed'), payload: filedPayload }),
71
+ z.object({ ...base, type: z.literal('boxxy.item.edited'), payload: editedPayload }),
72
+ z.object({ ...base, type: z.literal('boxxy.item.closed'), payload: closedPayload }),
73
+ z.object({ ...base, type: z.literal('boxxy.attempt.claimed'), payload: claimedPayload }),
74
+ z.object({ ...base, type: sessionTypes, payload: sessionPayload }),
75
+ z.object({ ...base, type: z.literal('boxxy.attempt.session_bound'), payload: boundPayload }),
76
+ z.object({ ...base, type: markTypes, payload: markPayload }),
77
+ z.object({ ...base, type: z.literal('boxxy.attempt.settled'), payload: settledPayload }),
78
+ z.object({ ...base, type: z.literal('boxxy.pull_request.linked'), payload: linkedPayload }),
79
+ ]);
80
+
81
+ // One fact of a batch as the consumer reads it from its scope, with the fact ID that ingest gives
82
+ // it. The payload is read the way the glue's decoder reads it, so a field the fold needs and the
83
+ // model lacks fails.
84
+ export function toScopeFact(fact: OutgoingFact<typeof boxxySource>): WorkFact {
85
+ return scopeFactSchema.parse({
86
+ id: `event:boxxy:${fact.event}`,
87
+ type: fact.type,
88
+ entity: fact.entity,
89
+ at: fact.at,
90
+ payload: fact.payload,
91
+ });
92
+ }