@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.
- package/dist/generated/boxxy-work-boundary.d.ts +15 -7
- package/dist/queries/types.d.ts +2 -2
- package/package.json +39 -10
- package/src/generated/boxxy-work-boundary.ts +179 -21
- package/src/queries/build-stage-time.ts +11 -4
- package/src/queries/types.ts +2 -2
- package/src/test-utils/build-foreign-stand-in.ts +139 -0
- package/src/test-utils/build-mock-applied-fact.ts +39 -0
- package/src/test-utils/build-mock-attempt-read-row.ts +17 -0
- package/src/test-utils/build-mock-binding-read-row.ts +12 -0
- package/src/test-utils/build-mock-item-facts.ts +149 -0
- package/src/test-utils/build-mock-request-read-row.ts +20 -0
- package/src/test-utils/build-mock-scope-fact.ts +13 -0
- package/src/test-utils/create-ingest-stand-in.ts +56 -0
- package/src/test-utils/create-query-stand-in.ts +51 -0
- package/src/test-utils/create-real-pepys.ts +286 -0
- package/src/test-utils/create-test-pepys.ts +228 -0
- package/src/test-utils/to-entity-id.ts +14 -0
- package/src/test-utils/to-scope-fact.ts +92 -0
|
@@ -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
|
+
}
|