@boxxy/pepys 0.1.3 → 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.
- package/dist/queries/types.d.ts +2 -2
- package/package.json +10 -4
- 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-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
package/dist/queries/types.d.ts
CHANGED
|
@@ -62,8 +62,8 @@ interface NullableColumn<Kind extends ColumnKind> {
|
|
|
62
62
|
}
|
|
63
63
|
type TelemetryColumns = Readonly<{
|
|
64
64
|
session_id: Column<'entityID'>;
|
|
65
|
-
origin_issuer:
|
|
66
|
-
origin_subject:
|
|
65
|
+
origin_issuer: Column<'text'>;
|
|
66
|
+
origin_subject: Column<'text'>;
|
|
67
67
|
product: Column<'text'>;
|
|
68
68
|
at: Column<'timestamp'>;
|
|
69
69
|
}>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/pepys",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The pepys plug-in of boxxy, its fact sink and its report reader",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/boxxy/tree/main/packages/pepys#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
15
|
"!src/**/*.test.ts",
|
|
16
|
-
"!src/test-utils",
|
|
17
16
|
"dist"
|
|
18
17
|
],
|
|
19
18
|
"type": "module",
|
|
@@ -29,7 +28,8 @@
|
|
|
29
28
|
"./reader": {
|
|
30
29
|
"types": "./dist/create-pepys-reader.d.ts",
|
|
31
30
|
"default": "./src/create-pepys-reader.ts"
|
|
32
|
-
}
|
|
31
|
+
},
|
|
32
|
+
"./test-utils/*": "./src/test-utils/*.ts"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
35
|
"access": "public"
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"typecheck": "tsrs --noEmit"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@boxxy/model": "0.1.
|
|
43
|
+
"@boxxy/model": "0.1.4"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@pepys/cli": "https://github.com/zgeoff/pepys/releases/download/preview-kit-0.0.1/pepys-cli-0.0.1.tgz",
|
|
@@ -52,8 +52,14 @@
|
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@pepys/kit": "0.0.1",
|
|
55
|
+
"@pepys/testing": "0.0.1",
|
|
55
56
|
"zod": "4.6.7"
|
|
56
57
|
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@pepys/testing": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
57
63
|
"engines": {
|
|
58
64
|
"bun": ">=1.3",
|
|
59
65
|
"node": ">=24"
|
|
@@ -170,16 +170,23 @@ interface SettledAttempt extends MeasuredAttempt {
|
|
|
170
170
|
readonly ended_at: string;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
// For each settled build that was built: the claim of the next
|
|
174
|
-
//
|
|
173
|
+
// For each settled build that was built: the claim of the review claimed next at or after the
|
|
174
|
+
// build's end, less that end. An attempt's number counts within its phase, so it cannot order a
|
|
175
|
+
// build against a review. null when no build has a review after it.
|
|
175
176
|
function measureReviewWait(attempts: readonly MeasuredAttempt[]): number | null {
|
|
176
177
|
const waits = attempts
|
|
177
178
|
.filter((attempt): attempt is SettledAttempt => attempt.ended_at !== null)
|
|
178
179
|
.filter((build) => build.phase === 'build' && build.outcome === outcomeNames.built)
|
|
179
180
|
.flatMap((build) => {
|
|
180
181
|
const [review] = attempts
|
|
181
|
-
.filter(
|
|
182
|
-
|
|
182
|
+
.filter(
|
|
183
|
+
(attempt) =>
|
|
184
|
+
attempt.phase === 'review' && measureMinutes(build.ended_at, attempt.claimed_at) >= 0,
|
|
185
|
+
)
|
|
186
|
+
.toSorted(
|
|
187
|
+
(left, right) =>
|
|
188
|
+
measureMinutes(right.claimed_at, left.claimed_at) || left.id.localeCompare(right.id),
|
|
189
|
+
);
|
|
183
190
|
|
|
184
191
|
return review === undefined ? [] : [measureMinutes(build.ended_at, review.claimed_at)];
|
|
185
192
|
});
|
package/src/queries/types.ts
CHANGED
|
@@ -87,8 +87,8 @@ interface NullableColumn<Kind extends ColumnKind> {
|
|
|
87
87
|
// columns fits. A table's columns take a type alias: an interface has no index signature.
|
|
88
88
|
type TelemetryColumns = Readonly<{
|
|
89
89
|
session_id: Column<'entityID'>;
|
|
90
|
-
origin_issuer:
|
|
91
|
-
origin_subject:
|
|
90
|
+
origin_issuer: Column<'text'>;
|
|
91
|
+
origin_subject: Column<'text'>;
|
|
92
92
|
product: Column<'text'>;
|
|
93
93
|
at: Column<'timestamp'>;
|
|
94
94
|
}>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineConsumer,
|
|
3
|
+
defineFactFamily,
|
|
4
|
+
definePlugin,
|
|
5
|
+
defineSource,
|
|
6
|
+
defineTable,
|
|
7
|
+
} from '@pepys/kit';
|
|
8
|
+
import type { DeliveryReadRow, ForeignReads } from '../queries/types.ts';
|
|
9
|
+
|
|
10
|
+
// No fact reaches a stand-in consumer: a test writes its rows. Its entry is this file, which
|
|
11
|
+
// exports no `applyFact`, so a fact sent to one fails the apply.
|
|
12
|
+
const entry = new URL(import.meta.url);
|
|
13
|
+
|
|
14
|
+
const logRecordFamily = defineFactFamily({
|
|
15
|
+
stream: 'facts.telemetry.log_record',
|
|
16
|
+
api: 'otlp-logs/1',
|
|
17
|
+
entityKind: 'telemetry:session',
|
|
18
|
+
types: {},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const pullRequestFamily = defineFactFamily({
|
|
22
|
+
stream: 'facts.github.pull_request',
|
|
23
|
+
api: 'github-webhook/1',
|
|
24
|
+
entityKind: 'github:pull_request',
|
|
25
|
+
types: {},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const telemetryColumns = {
|
|
29
|
+
id: { kind: 'entityID' },
|
|
30
|
+
session_id: { kind: 'entityID' },
|
|
31
|
+
origin_issuer: { kind: 'text' },
|
|
32
|
+
origin_subject: { kind: 'text' },
|
|
33
|
+
product: { kind: 'text' },
|
|
34
|
+
at: { kind: 'timestamp' },
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
const telemetryModelRequestConsumer = defineConsumer({
|
|
38
|
+
name: 'telemetry_model_request',
|
|
39
|
+
entry,
|
|
40
|
+
input: { mode: 'fact', streams: [logRecordFamily.stream] },
|
|
41
|
+
tables: {
|
|
42
|
+
telemetry_model_request: defineTable({
|
|
43
|
+
columns: {
|
|
44
|
+
...telemetryColumns,
|
|
45
|
+
model: { kind: 'text' },
|
|
46
|
+
cost_basis: { kind: 'text' },
|
|
47
|
+
cost_usd: { kind: 'real', nullable: true },
|
|
48
|
+
input_tokens: { kind: 'integer' },
|
|
49
|
+
cache_read_tokens: { kind: 'integer' },
|
|
50
|
+
cache_creation_tokens: { kind: 'integer' },
|
|
51
|
+
output_tokens: { kind: 'integer' },
|
|
52
|
+
},
|
|
53
|
+
primaryKey: ['id'],
|
|
54
|
+
indexes: [{ columns: ['session_id'] }],
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
entityViews: [],
|
|
58
|
+
reads: [],
|
|
59
|
+
links: [],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const telemetryTurnCostConsumer = defineConsumer({
|
|
63
|
+
name: 'telemetry_turn_cost',
|
|
64
|
+
entry,
|
|
65
|
+
input: { mode: 'fact', streams: [logRecordFamily.stream] },
|
|
66
|
+
tables: {
|
|
67
|
+
telemetry_turn_cost: defineTable({
|
|
68
|
+
columns: {
|
|
69
|
+
...telemetryColumns,
|
|
70
|
+
model: { kind: 'text', nullable: true },
|
|
71
|
+
cost_usd: { kind: 'real' },
|
|
72
|
+
},
|
|
73
|
+
primaryKey: ['id'],
|
|
74
|
+
indexes: [{ columns: ['session_id'] }],
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
entityViews: [],
|
|
78
|
+
reads: [],
|
|
79
|
+
links: [],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const githubPullRequestConsumer = defineConsumer({
|
|
83
|
+
name: 'github_pull_request',
|
|
84
|
+
entry,
|
|
85
|
+
input: { mode: 'fact', streams: [pullRequestFamily.stream] },
|
|
86
|
+
tables: {
|
|
87
|
+
github_pull_request: defineTable({
|
|
88
|
+
columns: {
|
|
89
|
+
id: { kind: 'entityID' },
|
|
90
|
+
merged_at: { kind: 'timestamp', nullable: true },
|
|
91
|
+
},
|
|
92
|
+
primaryKey: ['id'],
|
|
93
|
+
indexes: [],
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
entityViews: [],
|
|
97
|
+
reads: ['github_pull_request'],
|
|
98
|
+
links: [],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// The telemetry and GitHub plug-ins as far as the queries read them: the three consumers with the
|
|
102
|
+
// columns the queries name, and the GitHub delivery read as rows the test sets per item.
|
|
103
|
+
export function buildForeignStandIn() {
|
|
104
|
+
const deliveries = new Map<string, readonly DeliveryReadRow[]>();
|
|
105
|
+
|
|
106
|
+
const foreign: ForeignReads = {
|
|
107
|
+
telemetryModelRequest: telemetryModelRequestConsumer,
|
|
108
|
+
telemetryTurnCost: telemetryTurnCostConsumer,
|
|
109
|
+
githubPullRequest: githubPullRequestConsumer,
|
|
110
|
+
delivery: {
|
|
111
|
+
consumers: [githubPullRequestConsumer],
|
|
112
|
+
readDeliveries: (_db, input) => Promise.resolve(deliveries.get(input.item) ?? []),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
foreign,
|
|
118
|
+
plugins: [
|
|
119
|
+
definePlugin({
|
|
120
|
+
name: 'telemetry',
|
|
121
|
+
sources: [defineSource({ name: 'telemetry', families: [logRecordFamily] })],
|
|
122
|
+
consumers: [telemetryModelRequestConsumer, telemetryTurnCostConsumer],
|
|
123
|
+
}),
|
|
124
|
+
definePlugin({
|
|
125
|
+
name: 'github',
|
|
126
|
+
sources: [defineSource({ name: 'github', families: [pullRequestFamily] })],
|
|
127
|
+
consumers: [githubPullRequestConsumer],
|
|
128
|
+
}),
|
|
129
|
+
],
|
|
130
|
+
consumers: {
|
|
131
|
+
telemetryModelRequest: telemetryModelRequestConsumer,
|
|
132
|
+
telemetryTurnCost: telemetryTurnCostConsumer,
|
|
133
|
+
githubPullRequest: githubPullRequestConsumer,
|
|
134
|
+
},
|
|
135
|
+
setDeliveries: (item: string, rows: readonly DeliveryReadRow[]) => {
|
|
136
|
+
deliveries.set(item, rows);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { EntityRef } from '@boxxy/model';
|
|
2
|
+
import { buildMockWorkFact } from '@boxxy/model/test-utils/build-mock-work-fact';
|
|
3
|
+
import type { MockedFactType } from '@boxxy/model/test-utils/build-mock-work-fact';
|
|
4
|
+
import type { OutgoingFact } from '@pepys/kit';
|
|
5
|
+
import type { boxxySource } from '../boxxy-source.ts';
|
|
6
|
+
import { isOutgoingFact } from '../sink/is-outgoing-fact.ts';
|
|
7
|
+
import { toWireFact } from '../sink/to-wire-fact.ts';
|
|
8
|
+
|
|
9
|
+
interface AppliedFactOverrides {
|
|
10
|
+
readonly payload?: Readonly<Record<string, unknown>>;
|
|
11
|
+
|
|
12
|
+
// ISO 8601, as boxxy's sink writes it
|
|
13
|
+
readonly at?: string;
|
|
14
|
+
readonly entity?: EntityRef;
|
|
15
|
+
readonly event?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// One fact of a batch as boxxy's sink sends it: a model fact whose payload its schema accepts, in
|
|
19
|
+
// the sink's wire form. Ingest gives it the fact ID `event:boxxy:<event>`.
|
|
20
|
+
export function buildMockAppliedFact(
|
|
21
|
+
type: MockedFactType,
|
|
22
|
+
overrides: AppliedFactOverrides = {},
|
|
23
|
+
): OutgoingFact<typeof boxxySource> {
|
|
24
|
+
const wire = toWireFact(
|
|
25
|
+
buildMockWorkFact(type, {
|
|
26
|
+
...(overrides.payload === undefined ? {} : { payload: overrides.payload }),
|
|
27
|
+
...(overrides.entity === undefined ? {} : { entity: overrides.entity }),
|
|
28
|
+
at: Date.parse(overrides.at ?? '2026-11-11T02:00:00.000Z'),
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const fact = { ...wire, event: overrides.event ?? wire.event };
|
|
33
|
+
|
|
34
|
+
if (!isOutgoingFact(fact)) {
|
|
35
|
+
throw new Error(`a mock ${type} fact fails the schema of the boxxy fact family`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return fact;
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AttemptReadRow } from '../queries/types.ts';
|
|
2
|
+
|
|
3
|
+
export function buildMockAttemptReadRow(overrides: Partial<AttemptReadRow> = {}): AttemptReadRow {
|
|
4
|
+
return {
|
|
5
|
+
id: 'boxxy:attempt:0193a1b2-0000-7000-8000-0000000000a1',
|
|
6
|
+
phase: 'build',
|
|
7
|
+
number: 1,
|
|
8
|
+
round: 1,
|
|
9
|
+
state: 'succeeded',
|
|
10
|
+
outcome: null,
|
|
11
|
+
failure: null,
|
|
12
|
+
claimed_at: '2026-11-11T02:00:00.000Z',
|
|
13
|
+
ended_at: null,
|
|
14
|
+
sleeps: [],
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BindingReadRow } from '../queries/types.ts';
|
|
2
|
+
|
|
3
|
+
export function buildMockBindingReadRow(overrides: Partial<BindingReadRow> = {}): BindingReadRow {
|
|
4
|
+
return {
|
|
5
|
+
attempt_id: 'boxxy:attempt:0193a1b2-0000-7000-8000-0000000000a1',
|
|
6
|
+
session_id: 'telemetry:session:s-build',
|
|
7
|
+
bound_at: '2026-11-11T02:00:05.000Z',
|
|
8
|
+
caller_issuer: 'https://broker.example',
|
|
9
|
+
caller_subject: 'imp-builder',
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { OutgoingFact } from '@pepys/kit';
|
|
2
|
+
import type { boxxySource } from '../boxxy-source.ts';
|
|
3
|
+
import type { WorkFact } from '../work/types.ts';
|
|
4
|
+
import { buildMockAppliedFact } from './build-mock-applied-fact.ts';
|
|
5
|
+
import { toScopeFact } from './to-scope-fact.ts';
|
|
6
|
+
|
|
7
|
+
type AppliedFact = OutgoingFact<typeof boxxySource>;
|
|
8
|
+
|
|
9
|
+
interface ItemFacts {
|
|
10
|
+
// the facts as boxxy's sink sends them
|
|
11
|
+
readonly applied: AppliedFact[];
|
|
12
|
+
|
|
13
|
+
// the same facts as the consumer reads them from the item's scope
|
|
14
|
+
readonly facts: WorkFact[];
|
|
15
|
+
readonly attempts: {
|
|
16
|
+
readonly triage: string;
|
|
17
|
+
readonly build: string;
|
|
18
|
+
readonly review: string;
|
|
19
|
+
readonly rebuild: string;
|
|
20
|
+
readonly rereview: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// One item's facts in time order, events `f01` to `f21`: a triage, a build that slept 20 minutes
|
|
25
|
+
// and linked pull request 9001, a review that asked for changes, a second build in the first
|
|
26
|
+
// build's atc session, and a review that passed.
|
|
27
|
+
export function buildMockItemFacts(): ItemFacts {
|
|
28
|
+
const attempts = {
|
|
29
|
+
triage: '0193a1b2-0000-7000-8000-0000000000a1',
|
|
30
|
+
build: '0193a1b2-0000-7000-8000-0000000000a2',
|
|
31
|
+
review: '0193a1b2-0000-7000-8000-0000000000a3',
|
|
32
|
+
rebuild: '0193a1b2-0000-7000-8000-0000000000a4',
|
|
33
|
+
rereview: '0193a1b2-0000-7000-8000-0000000000a5',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const applied = [
|
|
37
|
+
buildItemFact('boxxy.item.filed', {
|
|
38
|
+
event: 'f01',
|
|
39
|
+
at: '2026-11-11T02:00:00.000Z',
|
|
40
|
+
payload: {
|
|
41
|
+
id: '0193a1b2-0000-7000-8000-000000000001',
|
|
42
|
+
kind: 'core.issue',
|
|
43
|
+
title: 'Chart the shoals',
|
|
44
|
+
repo: 'shoal/charts',
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
buildClaimed('f02', '02:01:00', { attempt: attempts.triage, phase: 'triage', number: 1 }),
|
|
48
|
+
buildStarted('f03', '02:01:05', attempts.triage, 's-triage'),
|
|
49
|
+
buildSettled('f04', '02:05:00', { attempt: attempts.triage, outcome: 'accepted' }),
|
|
50
|
+
buildClaimed('f05', '02:10:00', { attempt: attempts.build, phase: 'build', number: 1 }),
|
|
51
|
+
buildStarted('f06', '02:10:05', attempts.build, 's-build'),
|
|
52
|
+
buildItemFact('boxxy.attempt.session_bound', {
|
|
53
|
+
event: 'f07',
|
|
54
|
+
at: '2026-11-11T02:10:05.000Z',
|
|
55
|
+
payload: {
|
|
56
|
+
attempt: attempts.build,
|
|
57
|
+
atcSession: 's-build',
|
|
58
|
+
agentSession: 'agent-7',
|
|
59
|
+
product: 'claude_code',
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
buildItemFact('boxxy.pull_request.linked', {
|
|
63
|
+
event: 'f08',
|
|
64
|
+
at: '2026-11-11T02:25:00.000Z',
|
|
65
|
+
payload: { attempt: attempts.build, pullRequestId: '9001' },
|
|
66
|
+
}),
|
|
67
|
+
buildMark('boxxy.attempt.slept', 'f09', '02:30:00', attempts.build),
|
|
68
|
+
buildMark('boxxy.attempt.woken', 'f10', '02:50:00', attempts.build),
|
|
69
|
+
buildMark('boxxy.attempt.closed_out', 'f11', '03:09:00', attempts.build),
|
|
70
|
+
buildSettled('f12', '03:10:00', { attempt: attempts.build, outcome: 'built' }),
|
|
71
|
+
buildClaimed('f13', '03:16:00', { attempt: attempts.review, phase: 'review', number: 1 }),
|
|
72
|
+
buildStarted('f14', '03:16:05', attempts.review, 's-review'),
|
|
73
|
+
buildSettled('f15', '03:36:00', { attempt: attempts.review, outcome: 'changes-requested' }),
|
|
74
|
+
buildClaimed('f16', '03:40:00', {
|
|
75
|
+
attempt: attempts.rebuild,
|
|
76
|
+
phase: 'build',
|
|
77
|
+
number: 2,
|
|
78
|
+
round: 2,
|
|
79
|
+
}),
|
|
80
|
+
buildStarted('f17', '03:40:05', attempts.rebuild, 's-build'),
|
|
81
|
+
buildSettled('f18', '04:10:00', { attempt: attempts.rebuild, outcome: 'built', round: 2 }),
|
|
82
|
+
buildClaimed('f19', '04:12:00', {
|
|
83
|
+
attempt: attempts.rereview,
|
|
84
|
+
phase: 'review',
|
|
85
|
+
number: 2,
|
|
86
|
+
round: 2,
|
|
87
|
+
}),
|
|
88
|
+
buildStarted('f20', '04:12:05', attempts.rereview, 's-rereview'),
|
|
89
|
+
buildSettled('f21', '04:30:00', { attempt: attempts.rereview, outcome: 'passed', round: 2 }),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
return { applied, facts: applied.map((fact) => toScopeFact(fact)), attempts };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildItemFact(
|
|
96
|
+
type: Parameters<typeof buildMockAppliedFact>[0],
|
|
97
|
+
overrides: Parameters<typeof buildMockAppliedFact>[1],
|
|
98
|
+
): AppliedFact {
|
|
99
|
+
return buildMockAppliedFact(type, {
|
|
100
|
+
...overrides,
|
|
101
|
+
entity: 'boxxy:item:0193a1b2-0000-7000-8000-000000000001',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildClaimed(event: string, time: string, payload: Record<string, unknown>): AppliedFact {
|
|
106
|
+
return buildItemFact('boxxy.attempt.claimed', {
|
|
107
|
+
event,
|
|
108
|
+
at: `2026-11-11T${time}.000Z`,
|
|
109
|
+
payload: { round: 1, ...payload },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildStarted(
|
|
114
|
+
event: string,
|
|
115
|
+
time: string,
|
|
116
|
+
attempt: string,
|
|
117
|
+
atcSession: string,
|
|
118
|
+
): AppliedFact {
|
|
119
|
+
return buildItemFact('boxxy.attempt.started', {
|
|
120
|
+
event,
|
|
121
|
+
at: `2026-11-11T${time}.000Z`,
|
|
122
|
+
payload: {
|
|
123
|
+
attempt,
|
|
124
|
+
atcSession,
|
|
125
|
+
caller: { issuer: 'https://broker.example', subject: 'imp-builder' },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildSettled(event: string, time: string, payload: Record<string, unknown>): AppliedFact {
|
|
131
|
+
return buildItemFact('boxxy.attempt.settled', {
|
|
132
|
+
event,
|
|
133
|
+
at: `2026-11-11T${time}.000Z`,
|
|
134
|
+
payload,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildMark(
|
|
139
|
+
type: 'boxxy.attempt.slept' | 'boxxy.attempt.woken' | 'boxxy.attempt.closed_out',
|
|
140
|
+
event: string,
|
|
141
|
+
time: string,
|
|
142
|
+
attempt: string,
|
|
143
|
+
): AppliedFact {
|
|
144
|
+
return buildItemFact(type, {
|
|
145
|
+
event,
|
|
146
|
+
at: `2026-11-11T${time}.000Z`,
|
|
147
|
+
payload: { attempt },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RequestReadRow } from '../queries/types.ts';
|
|
2
|
+
|
|
3
|
+
// The origin matches the caller of the default binding row, so the two join.
|
|
4
|
+
export function buildMockRequestReadRow(overrides: Partial<RequestReadRow> = {}): RequestReadRow {
|
|
5
|
+
return {
|
|
6
|
+
session_id: 'telemetry:session:s-build',
|
|
7
|
+
origin_issuer: 'https://broker.example',
|
|
8
|
+
origin_subject: 'imp-builder',
|
|
9
|
+
product: 'claude_code',
|
|
10
|
+
at: '2026-11-11T02:20:00.000Z',
|
|
11
|
+
model: 'model-large',
|
|
12
|
+
cost_basis: 'reported',
|
|
13
|
+
cost_usd: 0,
|
|
14
|
+
input_tokens: 10,
|
|
15
|
+
cache_read_tokens: 100,
|
|
16
|
+
cache_creation_tokens: 0,
|
|
17
|
+
output_tokens: 20,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MockedFactType } from '@boxxy/model/test-utils/build-mock-work-fact';
|
|
2
|
+
import type { WorkFact } from '../work/types.ts';
|
|
3
|
+
import { buildMockAppliedFact } from './build-mock-applied-fact.ts';
|
|
4
|
+
import { toScopeFact } from './to-scope-fact.ts';
|
|
5
|
+
|
|
6
|
+
// One fact as the consumer reads it from its scope: a model fact through the sink's wire form,
|
|
7
|
+
// with the fact ID that ingest gives it.
|
|
8
|
+
export function buildMockScopeFact(
|
|
9
|
+
type: MockedFactType,
|
|
10
|
+
overrides: Parameters<typeof buildMockAppliedFact>[1] = {},
|
|
11
|
+
): WorkFact {
|
|
12
|
+
return toScopeFact(buildMockAppliedFact(type, overrides));
|
|
13
|
+
}
|
|
@@ -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
|
+
}
|