@boxxy/pepys 0.0.1 → 0.1.3

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 (51) 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 +45 -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 +188 -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/types.ts +14 -0
  47. package/src/wire-fact-schemas.ts +41 -0
  48. package/src/work/boxxy-work-consumer.ts +95 -0
  49. package/src/work/constants.ts +25 -0
  50. package/src/work/fold-work-facts.ts +490 -0
  51. package/src/work/types.ts +177 -0
@@ -0,0 +1,16 @@
1
+ import { buildEntityID, parseEntityID } from '@pepys/kit';
2
+ import type { EntityID } from '@pepys/kit';
3
+
4
+ // The item of a query's input as an entity ID. undefined: the text names no boxxy item, so the
5
+ // query answers as for an item with no row.
6
+ export function findItemEntityID(text: string): EntityID | undefined {
7
+ const parsed = parseEntityID(text);
8
+
9
+ if (!parsed.ok || parsed.value.source !== 'boxxy' || parsed.value.kind !== 'item') {
10
+ return undefined;
11
+ }
12
+
13
+ const built = buildEntityID(parsed.value);
14
+
15
+ return built.ok ? built.value : undefined;
16
+ }
@@ -0,0 +1,168 @@
1
+ import type {
2
+ ColumnKind,
3
+ ConsumerDeclaration,
4
+ EntityID,
5
+ QueryCall,
6
+ TableDeclaration,
7
+ } from '@pepys/kit';
8
+ import type { boxxyWorkConsumer } from '../work/boxxy-work-consumer.ts';
9
+
10
+ // The rows the statements of the two queries read. Each holds the columns of its table that the
11
+ // builders need, under the column names.
12
+
13
+ export interface AttemptReadRow {
14
+ id: string;
15
+ phase: string;
16
+ number: number;
17
+ round: number;
18
+ state: string;
19
+ outcome: string | null;
20
+ failure: string | null;
21
+ claimed_at: string;
22
+ ended_at: string | null;
23
+
24
+ // a JSON column: the list of sleep stretches, as text or as the parsed list
25
+ sleeps: unknown;
26
+ }
27
+
28
+ // A row of `boxxy_attempt_session` for every session that an attempt of the item used, with the
29
+ // rows of the other attempts, of any item, that used the same session.
30
+ export interface BindingReadRow {
31
+ attempt_id: string;
32
+ session_id: string;
33
+ bound_at: string;
34
+ caller_issuer: string | null;
35
+ caller_subject: string | null;
36
+ }
37
+
38
+ // the proven sender of a telemetry row, and its session and time
39
+ interface TelemetryReadRow {
40
+ session_id: string;
41
+ origin_issuer: string | null;
42
+ origin_subject: string | null;
43
+ product: string;
44
+ at: string;
45
+ }
46
+
47
+ export interface RequestReadRow extends TelemetryReadRow {
48
+ model: string;
49
+ cost_basis: string;
50
+ cost_usd: number | null;
51
+ input_tokens: number;
52
+ cache_read_tokens: number;
53
+ cache_creation_tokens: number;
54
+ output_tokens: number;
55
+ }
56
+
57
+ export interface TurnReadRow extends TelemetryReadRow {
58
+ model: string | null;
59
+ cost_usd: number;
60
+ }
61
+
62
+ // one pull request that an attempt of the item produced
63
+ export interface PullRequestReadRow {
64
+ id: string;
65
+ merged_at: string | null;
66
+ }
67
+
68
+ // One pull request of the item with its delivery times, as the GitHub plug-in's delivery module
69
+ // gives them. Two attempts that produced one pull request give two equal rows.
70
+ export interface DeliveryReadRow {
71
+ pullRequest: string;
72
+ mergedAt: string | null;
73
+ release: { at: string } | null;
74
+ deploy: { at: string } | null;
75
+ }
76
+
77
+ interface Column<Kind extends ColumnKind> {
78
+ readonly kind: Kind;
79
+ }
80
+
81
+ interface NullableColumn<Kind extends ColumnKind> {
82
+ readonly kind: Kind;
83
+ readonly nullable: true;
84
+ }
85
+
86
+ // The columns that the queries read of each consumer of another plug-in. A consumer with more
87
+ // columns fits. A table's columns take a type alias: an interface has no index signature.
88
+ type TelemetryColumns = Readonly<{
89
+ session_id: Column<'entityID'>;
90
+ origin_issuer: NullableColumn<'text'>;
91
+ origin_subject: NullableColumn<'text'>;
92
+ product: Column<'text'>;
93
+ at: Column<'timestamp'>;
94
+ }>;
95
+
96
+ export type TelemetryModelRequestConsumer = ConsumerDeclaration<
97
+ 'telemetry_model_request',
98
+ {
99
+ readonly telemetry_model_request: TableDeclaration<
100
+ TelemetryColumns & {
101
+ readonly model: Column<'text'>;
102
+ readonly cost_basis: Column<'text'>;
103
+ readonly cost_usd: NullableColumn<'real'>;
104
+ readonly input_tokens: Column<'integer'>;
105
+ readonly cache_read_tokens: Column<'integer'>;
106
+ readonly cache_creation_tokens: Column<'integer'>;
107
+ readonly output_tokens: Column<'integer'>;
108
+ }
109
+ >;
110
+ }
111
+ >;
112
+
113
+ export type TelemetryTurnCostConsumer = ConsumerDeclaration<
114
+ 'telemetry_turn_cost',
115
+ {
116
+ readonly telemetry_turn_cost: TableDeclaration<
117
+ TelemetryColumns & {
118
+ readonly model: NullableColumn<'text'>;
119
+ readonly cost_usd: Column<'real'>;
120
+ }
121
+ >;
122
+ }
123
+ >;
124
+
125
+ export type GithubPullRequestConsumer = ConsumerDeclaration<
126
+ 'github_pull_request',
127
+ {
128
+ readonly github_pull_request: TableDeclaration<{
129
+ readonly id: Column<'entityID'>;
130
+ readonly merged_at: NullableColumn<'timestamp'>;
131
+ }>;
132
+ }
133
+ >;
134
+
135
+ // A consumer whose tables the stage-time query does not type: the delivery read types its own,
136
+ // so the query's statement on the boxxy tables keeps one table type. No table name holds a colon,
137
+ // so the key pattern matches no real table and any consumer fits.
138
+ export type OpaqueConsumer = ConsumerDeclaration<
139
+ string,
140
+ Readonly<Record<`opaque:${string}`, TableDeclaration>>
141
+ >;
142
+
143
+ export type StageTimeReads = readonly [typeof boxxyWorkConsumer, ...OpaqueConsumer[]];
144
+
145
+ export interface DeliveryInput {
146
+ readonly item: EntityID;
147
+
148
+ // the environment whose deploy counts
149
+ readonly environment: string;
150
+ }
151
+
152
+ // The GitHub plug-in's delivery times of each pull request that an attempt of the item produced,
153
+ // with the consumers that hold them.
154
+ export interface DeliveryReads {
155
+ readonly consumers: readonly OpaqueConsumer[];
156
+ readonly readDeliveries: (
157
+ db: QueryCall<unknown, StageTimeReads>['db'],
158
+ input: DeliveryInput,
159
+ ) => Promise<readonly DeliveryReadRow[]>;
160
+ }
161
+
162
+ // The consumers of the GitHub and telemetry plug-ins that the two queries read.
163
+ export interface ForeignReads {
164
+ readonly telemetryModelRequest: TelemetryModelRequestConsumer;
165
+ readonly telemetryTurnCost: TelemetryTurnCostConsumer;
166
+ readonly githubPullRequest: GithubPullRequestConsumer;
167
+ readonly delivery: DeliveryReads;
168
+ }
@@ -0,0 +1,19 @@
1
+ import type { FactBatchBody, OutgoingFact } from '@pepys/kit';
2
+ import type { boxxySource } from '../boxxy-source.ts';
3
+ import { workFamily } from '../boxxy-source.ts';
4
+
5
+ // The check that ingest makes of each fact of a batch: a type of the family, an entity of the
6
+ // family's kind, and a payload that the type's schema accepts.
7
+ export function isOutgoingFact(
8
+ fact: FactBatchBody['facts'][number],
9
+ ): fact is OutgoingFact<typeof boxxySource> {
10
+ return (
11
+ isFamilyType(fact.type) &&
12
+ fact.entity.startsWith(`${workFamily.entityKind}:`) &&
13
+ workFamily.types[fact.type].safeParse(fact.payload).success
14
+ );
15
+ }
16
+
17
+ function isFamilyType(type: string): type is keyof typeof workFamily.types {
18
+ return Object.hasOwn(workFamily.types, type);
19
+ }
@@ -0,0 +1,29 @@
1
+ import type { SinkAnswer } from '@boxxy/model';
2
+ import type { Result, SendFailure } from '@pepys/kit';
3
+
4
+ type SendResult = Result<{ stored: number }, SendFailure>;
5
+
6
+ // A refusal with 400, 413 or 422 is the batch's content, which a split can find. Ingest answers
7
+ // 500 when a source throws, it has no blob store, or its log refuses an append: no split fixes
8
+ // those, so a 500 blocks the outbox like any other refusal of the sink.
9
+ export function pickSinkAnswer(sent: SendResult | 'timeout', timeoutMs: number): SinkAnswer {
10
+ if (sent === 'timeout') {
11
+ return { kind: 'retry', reason: `no answer in ${timeoutMs} ms` };
12
+ }
13
+
14
+ if (sent.ok) {
15
+ return { kind: 'stored' };
16
+ }
17
+
18
+ const failure = sent.failure;
19
+
20
+ if (failure.kind === 'retry') {
21
+ return { kind: 'retry', reason: failure.reason };
22
+ }
23
+
24
+ const status = failure.status;
25
+
26
+ return status === 400 || status === 413 || status === 422
27
+ ? { kind: 'refused-batch', status, reason: failure.reason }
28
+ : { kind: 'refused-sink', status, reason: failure.reason };
29
+ }
@@ -0,0 +1,31 @@
1
+ import type { OutboxFact } from '@boxxy/model';
2
+ import type { FactBatchBody } from '@pepys/kit';
3
+
4
+ type WireFact = FactBatchBody['facts'][number];
5
+
6
+ // One outbox row as one fact of a batch. The event is the row's fact ID on every send, so a
7
+ // resend is a copy that pepys drops; ingest makes the fact ID `event:boxxy:<event>`. The store
8
+ // version goes into the payload here and nowhere else.
9
+ export function toWireFact(fact: OutboxFact): WireFact {
10
+ return {
11
+ type: fact.type,
12
+ event: fact.factId,
13
+ entity: fact.entity,
14
+ actor: fact.actor,
15
+ at: new Date(fact.at).toISOString(),
16
+ payload: { ...toPayloadFields(fact.payload), storeVersion: fact.seq },
17
+ };
18
+ }
19
+
20
+ type Payload = OutboxFact['payload'];
21
+
22
+ // Every catalog payload is an object; `Array.isArray` does not narrow a readonly list.
23
+ function toPayloadFields(payload: Payload): Readonly<Record<string, unknown>> {
24
+ return isPayloadObject(payload) ? payload : {};
25
+ }
26
+
27
+ function isPayloadObject(
28
+ payload: Payload,
29
+ ): payload is Extract<Payload, Readonly<Record<string, unknown>>> {
30
+ return typeof payload === 'object' && payload !== null && !Array.isArray(payload);
31
+ }
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
+ });