@boxxy/pepys 0.0.1 → 0.1.4

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 (63) hide show
  1. package/README.md +42 -5
  2. package/dist/boxxy-source.d.ts +636 -0
  3. package/dist/boxxy-work-plugin.d.ts +1 -0
  4. package/dist/build-boxxy-plugin.d.ts +181 -0
  5. package/dist/consumers/apply-scope.d.ts +5 -0
  6. package/dist/create-pepys-fact-sink.d.ts +9 -0
  7. package/dist/create-pepys-reader.d.ts +9 -0
  8. package/dist/generated/boxxy-work-boundary.d.ts +566 -0
  9. package/dist/index.d.ts +5 -0
  10. package/dist/queries/build-cost-to-land-query.d.ts +156 -0
  11. package/dist/queries/build-cost-to-land.d.ts +12 -0
  12. package/dist/queries/build-stage-time-query.d.ts +29 -0
  13. package/dist/queries/build-stage-time.d.ts +10 -0
  14. package/dist/queries/constants.d.ts +6 -0
  15. package/dist/queries/find-item-entity-id.d.ts +2 -0
  16. package/dist/queries/types.d.ts +109 -0
  17. package/dist/sink/is-outgoing-fact.d.ts +3 -0
  18. package/dist/sink/pick-sink-answer.d.ts +7 -0
  19. package/dist/sink/to-wire-fact.d.ts +5 -0
  20. package/dist/types.d.ts +6 -0
  21. package/dist/wire-fact-schemas.d.ts +319 -0
  22. package/dist/work/boxxy-work-consumer.d.ts +125 -0
  23. package/dist/work/constants.d.ts +3 -0
  24. package/dist/work/fold-work-facts.d.ts +2 -0
  25. package/dist/work/types.d.ts +147 -0
  26. package/package.json +51 -4
  27. package/src/boxxy-source.ts +25 -0
  28. package/src/boxxy-work-plugin.ts +11 -0
  29. package/src/build-boxxy-plugin.ts +18 -0
  30. package/src/consumers/apply-scope.ts +125 -0
  31. package/src/create-pepys-fact-sink.ts +64 -0
  32. package/src/create-pepys-reader.ts +79 -0
  33. package/src/generated/boxxy-work-boundary.ts +2923 -0
  34. package/src/generated/boxxy-work.sql +71 -0
  35. package/src/index.ts +17 -0
  36. package/src/queries/build-cost-to-land-query.ts +127 -0
  37. package/src/queries/build-cost-to-land.ts +226 -0
  38. package/src/queries/build-stage-time-query.ts +59 -0
  39. package/src/queries/build-stage-time.ts +195 -0
  40. package/src/queries/constants.ts +10 -0
  41. package/src/queries/find-item-entity-id.ts +16 -0
  42. package/src/queries/types.ts +168 -0
  43. package/src/sink/is-outgoing-fact.ts +19 -0
  44. package/src/sink/pick-sink-answer.ts +29 -0
  45. package/src/sink/to-wire-fact.ts +31 -0
  46. package/src/test-utils/build-foreign-stand-in.ts +139 -0
  47. package/src/test-utils/build-mock-applied-fact.ts +39 -0
  48. package/src/test-utils/build-mock-attempt-read-row.ts +17 -0
  49. package/src/test-utils/build-mock-binding-read-row.ts +12 -0
  50. package/src/test-utils/build-mock-item-facts.ts +149 -0
  51. package/src/test-utils/build-mock-request-read-row.ts +20 -0
  52. package/src/test-utils/build-mock-scope-fact.ts +13 -0
  53. package/src/test-utils/create-ingest-stand-in.ts +56 -0
  54. package/src/test-utils/create-query-stand-in.ts +51 -0
  55. package/src/test-utils/create-test-pepys.ts +228 -0
  56. package/src/test-utils/to-entity-id.ts +14 -0
  57. package/src/test-utils/to-scope-fact.ts +92 -0
  58. package/src/types.ts +14 -0
  59. package/src/wire-fact-schemas.ts +41 -0
  60. package/src/work/boxxy-work-consumer.ts +95 -0
  61. package/src/work/constants.ts +25 -0
  62. package/src/work/fold-work-facts.ts +490 -0
  63. package/src/work/types.ts +177 -0
@@ -0,0 +1,56 @@
1
+ import { isDeliveryAuthentic } from '@pepys/kit';
2
+ import { boxxySource } from '../boxxy-source.ts';
3
+
4
+ export interface IngestStandIn {
5
+ // the receive route of the boxxy source, for the sender's `fetch` option
6
+ readonly handleRequest: (request: Request) => Promise<Response>;
7
+
8
+ // the events of the stored facts, in arrival order, a copy counted once
9
+ readStored: () => Promise<readonly string[]>;
10
+ }
11
+
12
+ // Ingest's receive route in memory, with the kit's own signature check and batch read. The kit
13
+ // exports no route handler of ingest, so the answers are written here: 401, the read's 400 or
14
+ // 422, or the batch stored whole with a copy of an event dropped.
15
+ export function createIngestStandIn(secret: string): IngestStandIn {
16
+ const path = boxxySource.live;
17
+
18
+ if (path?.kind !== 'receive') {
19
+ throw new Error('the boxxy source has no receive path');
20
+ }
21
+
22
+ const stored: string[] = [];
23
+
24
+ return {
25
+ handleRequest: async (request) => {
26
+ const buffer = await request.arrayBuffer();
27
+
28
+ const body = new Uint8Array(buffer);
29
+
30
+ if (!isDeliveryAuthentic(path.check, secret, request.headers, body)) {
31
+ return Response.json({ error: 'the signature does not match' }, { status: 401 });
32
+ }
33
+
34
+ const received = path.read(
35
+ { headers: request.headers, body },
36
+ { name: boxxySource.name, families: boxxySource.families },
37
+ );
38
+
39
+ if (received.kind === 'refused') {
40
+ return Response.json({ error: received.reason }, { status: received.status });
41
+ }
42
+
43
+ if (received.kind === 'ignored') {
44
+ return Response.json({ status: 'ignored' }, { status: 202 });
45
+ }
46
+
47
+ const events = received.batch.facts.map((pending) => pending.fact.id);
48
+
49
+ stored.push(...events.filter((event) => !stored.includes(event)));
50
+
51
+ return Response.json({ status: 'stored', stored: events.length });
52
+ },
53
+ readStored: () =>
54
+ Promise.resolve(stored.map((event) => event.replace(`event:${boxxySource.name}:`, ''))),
55
+ };
56
+ }
@@ -0,0 +1,51 @@
1
+ import type { CostToLand } from '@boxxy/model';
2
+ import { buildCostToLand } from '../queries/build-cost-to-land.ts';
3
+ import { buildStageTime } from '../queries/build-stage-time.ts';
4
+ import type { UncheckedQueryClient } from '../types.ts';
5
+
6
+ export interface QueryStandIn {
7
+ readonly client: UncheckedQueryClient;
8
+
9
+ // the cost the query answers for the item's entity ID
10
+ setCost: (item: string, cost: CostToLand) => void;
11
+
12
+ // every later call fails as a refused connection does
13
+ takeDown: () => void;
14
+ }
15
+
16
+ // The two queries in memory. An item with no cost set answers what each query's builder gives for
17
+ // an item with no row.
18
+ export function createQueryStandIn(): QueryStandIn {
19
+ const costs = new Map<string, CostToLand>();
20
+
21
+ let isDown = false;
22
+
23
+ const answer = (value: () => unknown): Promise<unknown> =>
24
+ isDown ? Promise.reject(new Error('connect ECONNREFUSED')) : Promise.resolve(value());
25
+
26
+ return {
27
+ client: {
28
+ boxxy_cost_to_land: (input) =>
29
+ answer(
30
+ () =>
31
+ costs.get(input.item) ??
32
+ buildCostToLand({
33
+ item: input.item,
34
+ attempts: [],
35
+ bindings: [],
36
+ requests: [],
37
+ turns: [],
38
+ pullRequests: [],
39
+ }),
40
+ ),
41
+ boxxy_stage_time: (input) =>
42
+ answer(() => buildStageTime({ ...input, attempts: [], deliveries: [] })),
43
+ },
44
+ setCost: (item, cost) => {
45
+ costs.set(item, cost);
46
+ },
47
+ takeDown: () => {
48
+ isDown = true;
49
+ },
50
+ };
51
+ }
@@ -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
+ }
package/src/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginClient } from '@pepys/kit';
2
+ import type { buildBoxxyPlugin } from './build-boxxy-plugin.ts';
3
+
4
+ // The typed client of the plug-in's two queries on the pepys API's RPC route.
5
+ export type BoxxyQueryClient = PluginClient<ReturnType<typeof buildBoxxyPlugin>>;
6
+
7
+ // The same client as the reader takes it. Its types hold only when pepys runs this package's
8
+ // version, so the reader parses each answer. Any `BoxxyQueryClient` fits, and so does the pepys
9
+ // API's deployment client of a deployment that holds the plug-in.
10
+ export type UncheckedQueryClient = {
11
+ readonly [Query in keyof BoxxyQueryClient]: (
12
+ input: Parameters<BoxxyQueryClient[Query]>[0],
13
+ ) => Promise<unknown>;
14
+ };
@@ -0,0 +1,41 @@
1
+ import { workFactSchemas } from '@boxxy/model';
2
+ import type { FactType } from '@boxxy/model';
3
+ import { z } from 'zod';
4
+
5
+ // The sink adds the store version to every payload, and no model schema holds it, so the wire
6
+ // schema of a type is the model's schema plus that one field. These are the `types` of the fact
7
+ // family.
8
+ const storeVersion = { storeVersion: z.number().int().positive() };
9
+
10
+ export const wireFactSchemas = {
11
+ 'boxxy.item.filed': workFactSchemas['boxxy.item.filed'].extend(storeVersion),
12
+ 'boxxy.item.noted': workFactSchemas['boxxy.item.noted'].extend(storeVersion),
13
+ 'boxxy.item.edited': workFactSchemas['boxxy.item.edited'].extend(storeVersion),
14
+ 'boxxy.item.closed': workFactSchemas['boxxy.item.closed'].extend(storeVersion),
15
+ 'boxxy.relation.added': workFactSchemas['boxxy.relation.added'].extend(storeVersion),
16
+ 'boxxy.relation.removed': workFactSchemas['boxxy.relation.removed'].extend(storeVersion),
17
+ 'boxxy.lever.recorded': workFactSchemas['boxxy.lever.recorded'].extend(storeVersion),
18
+ 'boxxy.attempt.claimed': workFactSchemas['boxxy.attempt.claimed'].extend(storeVersion),
19
+ 'boxxy.decision.recorded': workFactSchemas['boxxy.decision.recorded'].extend(storeVersion),
20
+ 'boxxy.attempt.started': workFactSchemas['boxxy.attempt.started'].extend(storeVersion),
21
+ 'boxxy.attempt.spawn_unanswered':
22
+ workFactSchemas['boxxy.attempt.spawn_unanswered'].extend(storeVersion),
23
+ 'boxxy.attempt.reconciled': workFactSchemas['boxxy.attempt.reconciled'].extend(storeVersion),
24
+ 'boxxy.attempt.session_bound':
25
+ workFactSchemas['boxxy.attempt.session_bound'].extend(storeVersion),
26
+ 'boxxy.attempt.slept': workFactSchemas['boxxy.attempt.slept'].extend(storeVersion),
27
+ 'boxxy.attempt.wake_requested':
28
+ workFactSchemas['boxxy.attempt.wake_requested'].extend(storeVersion),
29
+ 'boxxy.attempt.woken': workFactSchemas['boxxy.attempt.woken'].extend(storeVersion),
30
+ 'boxxy.attempt.resumed': workFactSchemas['boxxy.attempt.resumed'].extend(storeVersion),
31
+ 'boxxy.attempt.closed_out': workFactSchemas['boxxy.attempt.closed_out'].extend(storeVersion),
32
+ 'boxxy.attempt.timed_out': workFactSchemas['boxxy.attempt.timed_out'].extend(storeVersion),
33
+ 'boxxy.attempt.turn_ended': workFactSchemas['boxxy.attempt.turn_ended'].extend(storeVersion),
34
+ 'boxxy.attempt.settled': workFactSchemas['boxxy.attempt.settled'].extend(storeVersion),
35
+ 'boxxy.proposal.recorded': workFactSchemas['boxxy.proposal.recorded'].extend(storeVersion),
36
+ 'boxxy.evidence.recorded': workFactSchemas['boxxy.evidence.recorded'].extend(storeVersion),
37
+ 'boxxy.pull_request.linked': workFactSchemas['boxxy.pull_request.linked'].extend(storeVersion),
38
+ 'boxxy.commit.linked': workFactSchemas['boxxy.commit.linked'].extend(storeVersion),
39
+ 'boxxy.pull_request.merged': workFactSchemas['boxxy.pull_request.merged'].extend(storeVersion),
40
+ 'boxxy.pull_request.closed': workFactSchemas['boxxy.pull_request.closed'].extend(storeVersion),
41
+ } satisfies Readonly<Record<FactType, z.ZodObject>>;
@@ -0,0 +1,95 @@
1
+ import { defineConsumer, defineTable } from '@pepys/kit';
2
+ import { workFamily } from '../boxxy-source.ts';
3
+ import { attemptOf, produced, ranIn } from './constants.ts';
4
+
5
+ // One scope per item: a fact on the stream marks its item dirty, and the entry file gets every
6
+ // fact of the item and returns every row and link the item owns.
7
+ export const boxxyWorkConsumer = defineConsumer({
8
+ name: 'boxxy_work',
9
+ entry: new URL('../consumers/apply-scope.ts', import.meta.url),
10
+ input: { mode: 'scope', scopeKind: 'boxxy:item', streams: [workFamily.stream] },
11
+ tables: {
12
+ boxxy_item: defineTable({
13
+ columns: {
14
+ id: { kind: 'entityID' },
15
+ kind: { kind: 'text' },
16
+ repo: { kind: 'text' },
17
+ title: { kind: 'text' },
18
+ design_mode: { kind: 'text', nullable: true },
19
+ filed_at: { kind: 'timestamp' },
20
+ closed_at: { kind: 'timestamp', nullable: true },
21
+ close_reason: { kind: 'text', nullable: true },
22
+ updated_at: { kind: 'timestamp' },
23
+ },
24
+ primaryKey: ['id'],
25
+ indexes: [],
26
+ }),
27
+ boxxy_attempt: defineTable({
28
+ columns: {
29
+ id: { kind: 'entityID' },
30
+ item_id: { kind: 'entityID' },
31
+ phase: { kind: 'text' },
32
+ number: { kind: 'integer' },
33
+ round: { kind: 'integer' },
34
+ mode: { kind: 'text' },
35
+ pool: { kind: 'text' },
36
+ recipe_hash: { kind: 'text' },
37
+ rules_version: { kind: 'text' },
38
+ atc_session: { kind: 'text', nullable: true },
39
+ state: { kind: 'text' },
40
+ outcome: { kind: 'text', nullable: true },
41
+ failure: { kind: 'text', nullable: true },
42
+ claimed_at: { kind: 'timestamp' },
43
+ started_at: { kind: 'timestamp', nullable: true },
44
+ closed_out_at: { kind: 'timestamp', nullable: true },
45
+ ended_at: { kind: 'timestamp', nullable: true },
46
+
47
+ // the stretches of sleep, `{ from, to }` with `to` null while asleep: the entry file does
48
+ // no date arithmetic, so the queries add them up
49
+ sleeps: { kind: 'json' },
50
+ updated_at: { kind: 'timestamp' },
51
+ },
52
+ primaryKey: ['id'],
53
+ indexes: [{ columns: ['item_id'] }],
54
+ }),
55
+
56
+ // The join table of the cost query: one row per attempt and telemetry session. `key` `atc`
57
+ // holds the atc session, and `agent` the agent's own session, which matches only a telemetry
58
+ // row that carried no atc session ID.
59
+ boxxy_attempt_session: defineTable({
60
+ columns: {
61
+ attempt_id: { kind: 'entityID' },
62
+ session_id: { kind: 'entityID' },
63
+ key: { kind: 'text' },
64
+ product: { kind: 'text', nullable: true },
65
+ caller_issuer: { kind: 'text', nullable: true },
66
+ caller_subject: { kind: 'text', nullable: true },
67
+ bound_at: { kind: 'timestamp' },
68
+ },
69
+ primaryKey: ['attempt_id', 'session_id'],
70
+ indexes: [{ columns: ['session_id'] }],
71
+ }),
72
+ },
73
+ entityViews: [
74
+ {
75
+ table: 'boxxy_item',
76
+ kind: 'boxxy:item',
77
+ id: 'id',
78
+ title: 'title',
79
+ url: null,
80
+ actor: null,
81
+ updatedAt: 'updated_at',
82
+ },
83
+ {
84
+ table: 'boxxy_attempt',
85
+ kind: 'boxxy:attempt',
86
+ id: 'id',
87
+ title: 'phase',
88
+ url: null,
89
+ actor: null,
90
+ updatedAt: 'updated_at',
91
+ },
92
+ ],
93
+ reads: [],
94
+ links: [ranIn, produced, attemptOf],
95
+ });
@@ -0,0 +1,25 @@
1
+ import { defineLinkType } from '@pepys/kit';
2
+
3
+ // `boxxy:attempt:<attempt ID>` to `telemetry:session:<atc or agent session ID>`
4
+ export const ranIn = defineLinkType({
5
+ name: 'ran_in',
6
+ from: ['boxxy:attempt'],
7
+ to: ['telemetry:session'],
8
+ hub: false,
9
+ });
10
+
11
+ // `boxxy:attempt:<attempt ID>` to `github:pull_request:<GitHub's own ID of the pull request>`
12
+ export const produced = defineLinkType({
13
+ name: 'produced',
14
+ from: ['boxxy:attempt'],
15
+ to: ['github:pull_request'],
16
+ hub: false,
17
+ });
18
+
19
+ // `boxxy:attempt:<attempt ID>` to `boxxy:item:<item ID>`
20
+ export const attemptOf = defineLinkType({
21
+ name: 'attempt_of',
22
+ from: ['boxxy:attempt'],
23
+ to: ['boxxy:item'],
24
+ hub: false,
25
+ });