@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.
- package/README.md +42 -5
- package/dist/boxxy-source.d.ts +636 -0
- package/dist/boxxy-work-plugin.d.ts +1 -0
- package/dist/build-boxxy-plugin.d.ts +181 -0
- package/dist/consumers/apply-scope.d.ts +5 -0
- package/dist/create-pepys-fact-sink.d.ts +9 -0
- package/dist/create-pepys-reader.d.ts +9 -0
- package/dist/generated/boxxy-work-boundary.d.ts +566 -0
- package/dist/index.d.ts +5 -0
- package/dist/queries/build-cost-to-land-query.d.ts +156 -0
- package/dist/queries/build-cost-to-land.d.ts +12 -0
- package/dist/queries/build-stage-time-query.d.ts +29 -0
- package/dist/queries/build-stage-time.d.ts +10 -0
- package/dist/queries/constants.d.ts +6 -0
- package/dist/queries/find-item-entity-id.d.ts +2 -0
- package/dist/queries/types.d.ts +109 -0
- package/dist/sink/is-outgoing-fact.d.ts +3 -0
- package/dist/sink/pick-sink-answer.d.ts +7 -0
- package/dist/sink/to-wire-fact.d.ts +5 -0
- package/dist/types.d.ts +6 -0
- package/dist/wire-fact-schemas.d.ts +319 -0
- package/dist/work/boxxy-work-consumer.d.ts +125 -0
- package/dist/work/constants.d.ts +3 -0
- package/dist/work/fold-work-facts.d.ts +2 -0
- package/dist/work/types.d.ts +147 -0
- package/package.json +51 -4
- package/src/boxxy-source.ts +25 -0
- package/src/boxxy-work-plugin.ts +11 -0
- package/src/build-boxxy-plugin.ts +18 -0
- package/src/consumers/apply-scope.ts +125 -0
- package/src/create-pepys-fact-sink.ts +64 -0
- package/src/create-pepys-reader.ts +79 -0
- package/src/generated/boxxy-work-boundary.ts +2923 -0
- package/src/generated/boxxy-work.sql +71 -0
- package/src/index.ts +17 -0
- package/src/queries/build-cost-to-land-query.ts +127 -0
- package/src/queries/build-cost-to-land.ts +226 -0
- package/src/queries/build-stage-time-query.ts +59 -0
- package/src/queries/build-stage-time.ts +195 -0
- package/src/queries/constants.ts +10 -0
- package/src/queries/find-item-entity-id.ts +16 -0
- package/src/queries/types.ts +168 -0
- package/src/sink/is-outgoing-fact.ts +19 -0
- package/src/sink/pick-sink-answer.ts +29 -0
- package/src/sink/to-wire-fact.ts +31 -0
- 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/src/types.ts +14 -0
- package/src/wire-fact-schemas.ts +41 -0
- package/src/work/boxxy-work-consumer.ts +95 -0
- package/src/work/constants.ts +25 -0
- package/src/work/fold-work-facts.ts +490 -0
- package/src/work/types.ts +177 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- Generated by `pepys build`. Do not edit.
|
|
2
|
+
|
|
3
|
+
CREATE TABLE "boxxy_item" (
|
|
4
|
+
"id" TEXT NOT NULL,
|
|
5
|
+
"kind" TEXT NOT NULL,
|
|
6
|
+
"repo" TEXT NOT NULL,
|
|
7
|
+
"title" TEXT NOT NULL,
|
|
8
|
+
"design_mode" TEXT,
|
|
9
|
+
"filed_at" TEXT NOT NULL,
|
|
10
|
+
"closed_at" TEXT,
|
|
11
|
+
"close_reason" TEXT,
|
|
12
|
+
"updated_at" TEXT NOT NULL,
|
|
13
|
+
PRIMARY KEY ("id")
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE "boxxy_attempt" (
|
|
17
|
+
"id" TEXT NOT NULL,
|
|
18
|
+
"item_id" TEXT NOT NULL,
|
|
19
|
+
"phase" TEXT NOT NULL,
|
|
20
|
+
"number" INTEGER NOT NULL,
|
|
21
|
+
"round" INTEGER NOT NULL,
|
|
22
|
+
"mode" TEXT NOT NULL,
|
|
23
|
+
"pool" TEXT NOT NULL,
|
|
24
|
+
"recipe_hash" TEXT NOT NULL,
|
|
25
|
+
"rules_version" TEXT NOT NULL,
|
|
26
|
+
"atc_session" TEXT,
|
|
27
|
+
"state" TEXT NOT NULL,
|
|
28
|
+
"outcome" TEXT,
|
|
29
|
+
"failure" TEXT,
|
|
30
|
+
"claimed_at" TEXT NOT NULL,
|
|
31
|
+
"started_at" TEXT,
|
|
32
|
+
"closed_out_at" TEXT,
|
|
33
|
+
"ended_at" TEXT,
|
|
34
|
+
"sleeps" TEXT NOT NULL,
|
|
35
|
+
"updated_at" TEXT NOT NULL,
|
|
36
|
+
PRIMARY KEY ("id")
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX "boxxy_attempt_item_id_idx" ON "boxxy_attempt" ("item_id");
|
|
40
|
+
|
|
41
|
+
CREATE TABLE "boxxy_attempt_session" (
|
|
42
|
+
"attempt_id" TEXT NOT NULL,
|
|
43
|
+
"session_id" TEXT NOT NULL,
|
|
44
|
+
"key" TEXT NOT NULL,
|
|
45
|
+
"product" TEXT,
|
|
46
|
+
"caller_issuer" TEXT,
|
|
47
|
+
"caller_subject" TEXT,
|
|
48
|
+
"bound_at" TEXT NOT NULL,
|
|
49
|
+
PRIMARY KEY ("attempt_id", "session_id")
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX "boxxy_attempt_session_session_id_idx" ON "boxxy_attempt_session" ("session_id");
|
|
53
|
+
|
|
54
|
+
CREATE VIEW "entity" AS
|
|
55
|
+
SELECT
|
|
56
|
+
"id" AS "id",
|
|
57
|
+
'boxxy:item' AS "kind",
|
|
58
|
+
"title" AS "title",
|
|
59
|
+
NULL AS "url",
|
|
60
|
+
NULL AS "actor",
|
|
61
|
+
"updated_at" AS "updated_at"
|
|
62
|
+
FROM "boxxy_item"
|
|
63
|
+
UNION ALL
|
|
64
|
+
SELECT
|
|
65
|
+
"id" AS "id",
|
|
66
|
+
'boxxy:attempt' AS "kind",
|
|
67
|
+
"phase" AS "title",
|
|
68
|
+
NULL AS "url",
|
|
69
|
+
NULL AS "actor",
|
|
70
|
+
"updated_at" AS "updated_at"
|
|
71
|
+
FROM "boxxy_attempt";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { buildBoxxyPlugin } from './build-boxxy-plugin.ts';
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
DeliveryInput,
|
|
5
|
+
DeliveryReadRow,
|
|
6
|
+
DeliveryReads,
|
|
7
|
+
ForeignReads,
|
|
8
|
+
GithubPullRequestConsumer,
|
|
9
|
+
OpaqueConsumer,
|
|
10
|
+
StageTimeReads,
|
|
11
|
+
TelemetryModelRequestConsumer,
|
|
12
|
+
TelemetryTurnCostConsumer,
|
|
13
|
+
} from './queries/types.ts';
|
|
14
|
+
|
|
15
|
+
export { boxxySource } from './boxxy-source.ts';
|
|
16
|
+
export type { BoxxyQueryClient, UncheckedQueryClient } from './types.ts';
|
|
17
|
+
export { boxxyWorkConsumer } from './work/boxxy-work-consumer.ts';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { costToLandSchema } from '@boxxy/model';
|
|
2
|
+
import { defineQuery } from '@pepys/kit';
|
|
3
|
+
import type { EntityID, QueryCall } from '@pepys/kit';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { boxxyWorkConsumer } from '../work/boxxy-work-consumer.ts';
|
|
6
|
+
import { buildCostToLand } from './build-cost-to-land.ts';
|
|
7
|
+
import { findItemEntityID } from './find-item-entity-id.ts';
|
|
8
|
+
import type {
|
|
9
|
+
ForeignReads,
|
|
10
|
+
GithubPullRequestConsumer,
|
|
11
|
+
TelemetryModelRequestConsumer,
|
|
12
|
+
TelemetryTurnCostConsumer,
|
|
13
|
+
} from './types.ts';
|
|
14
|
+
|
|
15
|
+
// The cost query over the boxxy consumer and the telemetry and GitHub consumers. Each statement
|
|
16
|
+
// reads plain rows, and the builder does the caller match, the windows and the sums.
|
|
17
|
+
export function buildCostToLandQuery(foreign: ForeignReads) {
|
|
18
|
+
return defineQuery({
|
|
19
|
+
name: 'boxxy_cost_to_land',
|
|
20
|
+
description:
|
|
21
|
+
'Returns what one boxxy item cost to land: the model requests, tokens and cost of every attempt at the item, per phase, product and model, with rework apart. `landedAt` is null until a pull request of the item merges.',
|
|
22
|
+
input: z.object({ item: z.string().describe('The item, as `boxxy:item:<id>`') }),
|
|
23
|
+
output: costToLandSchema,
|
|
24
|
+
reads: [
|
|
25
|
+
boxxyWorkConsumer,
|
|
26
|
+
foreign.telemetryModelRequest,
|
|
27
|
+
foreign.telemetryTurnCost,
|
|
28
|
+
foreign.githubPullRequest,
|
|
29
|
+
],
|
|
30
|
+
handler: async (call) => {
|
|
31
|
+
const item = findItemEntityID(call.input.item);
|
|
32
|
+
|
|
33
|
+
if (item === undefined) {
|
|
34
|
+
return buildCostToLand({
|
|
35
|
+
item: call.input.item,
|
|
36
|
+
attempts: [],
|
|
37
|
+
bindings: [],
|
|
38
|
+
requests: [],
|
|
39
|
+
turns: [],
|
|
40
|
+
pullRequests: [],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return buildCostToLand({ item: call.input.item, ...(await readCostRows(call.db, item)) });
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type CostDB = QueryCall<
|
|
50
|
+
unknown,
|
|
51
|
+
readonly [
|
|
52
|
+
typeof boxxyWorkConsumer,
|
|
53
|
+
TelemetryModelRequestConsumer,
|
|
54
|
+
TelemetryTurnCostConsumer,
|
|
55
|
+
GithubPullRequestConsumer,
|
|
56
|
+
]
|
|
57
|
+
>['db'];
|
|
58
|
+
|
|
59
|
+
async function readCostRows(db: CostDB, item: EntityID) {
|
|
60
|
+
const sessions = db
|
|
61
|
+
.selectFrom('boxxy_attempt_session as own')
|
|
62
|
+
.innerJoin('boxxy_attempt as attempt', 'attempt.id', 'own.attempt_id')
|
|
63
|
+
.where('attempt.item_id', '=', item)
|
|
64
|
+
.select('own.session_id');
|
|
65
|
+
|
|
66
|
+
const attempts = await db
|
|
67
|
+
.selectFrom('boxxy_attempt')
|
|
68
|
+
.where('item_id', '=', item)
|
|
69
|
+
.select([
|
|
70
|
+
'id',
|
|
71
|
+
'phase',
|
|
72
|
+
'number',
|
|
73
|
+
'round',
|
|
74
|
+
'state',
|
|
75
|
+
'outcome',
|
|
76
|
+
'failure',
|
|
77
|
+
'claimed_at',
|
|
78
|
+
'ended_at',
|
|
79
|
+
'sleeps',
|
|
80
|
+
])
|
|
81
|
+
.orderBy('number')
|
|
82
|
+
.orderBy('id')
|
|
83
|
+
.execute();
|
|
84
|
+
|
|
85
|
+
const bindings = await db
|
|
86
|
+
.selectFrom('boxxy_attempt_session')
|
|
87
|
+
.where('session_id', 'in', sessions)
|
|
88
|
+
.select(['attempt_id', 'session_id', 'bound_at', 'caller_issuer', 'caller_subject'])
|
|
89
|
+
.execute();
|
|
90
|
+
|
|
91
|
+
const requests = await db
|
|
92
|
+
.selectFrom('telemetry_model_request')
|
|
93
|
+
.where('session_id', 'in', sessions)
|
|
94
|
+
.select([
|
|
95
|
+
'session_id',
|
|
96
|
+
'origin_issuer',
|
|
97
|
+
'origin_subject',
|
|
98
|
+
'product',
|
|
99
|
+
'model',
|
|
100
|
+
'cost_basis',
|
|
101
|
+
'cost_usd',
|
|
102
|
+
'input_tokens',
|
|
103
|
+
'cache_read_tokens',
|
|
104
|
+
'cache_creation_tokens',
|
|
105
|
+
'output_tokens',
|
|
106
|
+
'at',
|
|
107
|
+
])
|
|
108
|
+
.execute();
|
|
109
|
+
|
|
110
|
+
const turns = await db
|
|
111
|
+
.selectFrom('telemetry_turn_cost')
|
|
112
|
+
.where('session_id', 'in', sessions)
|
|
113
|
+
.select(['session_id', 'origin_issuer', 'origin_subject', 'product', 'model', 'cost_usd', 'at'])
|
|
114
|
+
.execute();
|
|
115
|
+
|
|
116
|
+
const pullRequests = await db
|
|
117
|
+
.selectFrom('link_effective as produced')
|
|
118
|
+
.innerJoin('boxxy_attempt as attempt', 'attempt.id', 'produced.from_id')
|
|
119
|
+
.leftJoin('github_pull_request as pull_request', 'pull_request.id', 'produced.to_id')
|
|
120
|
+
.where('produced.type', '=', 'produced')
|
|
121
|
+
.where('attempt.item_id', '=', item)
|
|
122
|
+
.select(['produced.to_id as id', 'pull_request.merged_at'])
|
|
123
|
+
.distinct()
|
|
124
|
+
.execute();
|
|
125
|
+
|
|
126
|
+
return { attempts, bindings, requests, turns, pullRequests };
|
|
127
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { CostToLand } from '@boxxy/model';
|
|
2
|
+
import { noCapacityFailure } from './constants.ts';
|
|
3
|
+
import type {
|
|
4
|
+
AttemptReadRow,
|
|
5
|
+
BindingReadRow,
|
|
6
|
+
PullRequestReadRow,
|
|
7
|
+
RequestReadRow,
|
|
8
|
+
TurnReadRow,
|
|
9
|
+
} from './types.ts';
|
|
10
|
+
|
|
11
|
+
interface CostParts {
|
|
12
|
+
readonly item: string;
|
|
13
|
+
readonly attempts: readonly AttemptReadRow[];
|
|
14
|
+
readonly bindings: readonly BindingReadRow[];
|
|
15
|
+
readonly requests: readonly RequestReadRow[];
|
|
16
|
+
readonly turns: readonly TurnReadRow[];
|
|
17
|
+
readonly pullRequests: readonly PullRequestReadRow[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// What one item cost to land, from the rows its statements read. Every attempt of the item counts,
|
|
21
|
+
// whatever its state or outcome, but a `no-capacity` one. Each telemetry row counts for at most one
|
|
22
|
+
// attempt, by the rule of `findOwner`.
|
|
23
|
+
export function buildCostToLand(parts: CostParts): CostToLand {
|
|
24
|
+
const attempts = parts.attempts.filter((attempt) => attempt.failure !== noCapacityFailure);
|
|
25
|
+
const owners = { attempts, bindings: parts.bindings };
|
|
26
|
+
const requestLines = collectRequestLines(owners, parts.requests);
|
|
27
|
+
const turnLines = collectTurnLines(owners, parts.turns);
|
|
28
|
+
const lines = [...requestLines, ...turnLines];
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
item: parts.item,
|
|
32
|
+
landedAt: findLandedAt(parts.pullRequests),
|
|
33
|
+
attempts: attempts.length,
|
|
34
|
+
lines,
|
|
35
|
+
totals: {
|
|
36
|
+
requests: sumOf(lines.map((line) => line.requests)),
|
|
37
|
+
costUSD: sumOf(lines.map((line) => line.costUSD ?? 0)),
|
|
38
|
+
reworkCostUSD: sumOf(lines.filter((line) => line.rework).map((line) => line.costUSD ?? 0)),
|
|
39
|
+
requestsWithoutCost: countRequestsWithoutCost(requestLines, turnLines),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type CostLine = CostToLand['lines'][number];
|
|
45
|
+
|
|
46
|
+
interface Owners {
|
|
47
|
+
readonly attempts: readonly AttemptReadRow[];
|
|
48
|
+
readonly bindings: readonly BindingReadRow[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One line per phase, rework flag, product, model and cost basis. A group whose every cost is
|
|
52
|
+
// unknown has a null cost, never 0.
|
|
53
|
+
function collectRequestLines(owners: Owners, requests: readonly RequestReadRow[]): CostLine[] {
|
|
54
|
+
const lines = new Map<string, CostLine>();
|
|
55
|
+
|
|
56
|
+
for (const request of requests) {
|
|
57
|
+
const attempt = findOwner(owners, request);
|
|
58
|
+
|
|
59
|
+
if (attempt !== undefined) {
|
|
60
|
+
const costBasis = pickRequestBasis(request.cost_basis);
|
|
61
|
+
const rework = attempt.round > 1;
|
|
62
|
+
|
|
63
|
+
const key = JSON.stringify([
|
|
64
|
+
attempt.phase,
|
|
65
|
+
rework,
|
|
66
|
+
request.product,
|
|
67
|
+
request.model,
|
|
68
|
+
costBasis,
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const line = lines.get(key) ?? {
|
|
72
|
+
phase: attempt.phase,
|
|
73
|
+
rework,
|
|
74
|
+
product: request.product,
|
|
75
|
+
model: request.model,
|
|
76
|
+
requests: 0,
|
|
77
|
+
inputTokens: 0,
|
|
78
|
+
cacheReadTokens: 0,
|
|
79
|
+
cacheCreationTokens: 0,
|
|
80
|
+
outputTokens: 0,
|
|
81
|
+
costUSD: null,
|
|
82
|
+
costBasis,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
lines.set(key, {
|
|
86
|
+
...line,
|
|
87
|
+
requests: line.requests + 1,
|
|
88
|
+
inputTokens: line.inputTokens + request.input_tokens,
|
|
89
|
+
cacheReadTokens: line.cacheReadTokens + request.cache_read_tokens,
|
|
90
|
+
cacheCreationTokens: line.cacheCreationTokens + request.cache_creation_tokens,
|
|
91
|
+
outputTokens: line.outputTokens + request.output_tokens,
|
|
92
|
+
costUSD: request.cost_usd === null ? line.costUSD : (line.costUSD ?? 0) + request.cost_usd,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return [...lines.values()].toSorted(compareLines);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface TelemetryRow {
|
|
101
|
+
readonly session_id: string;
|
|
102
|
+
readonly origin_issuer: string | null;
|
|
103
|
+
readonly origin_subject: string | null;
|
|
104
|
+
readonly at: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The attempt that owns the row's time in its session, when the row's proven origin is that
|
|
108
|
+
// attempt's caller. Times compare as instants: GitHub and some telemetry write `…:00Z`, and boxxy
|
|
109
|
+
// `…:00.000Z`, so a text compare would misorder them.
|
|
110
|
+
function findOwner(owners: Owners, row: TelemetryRow): AttemptReadRow | undefined {
|
|
111
|
+
const bindings = owners.bindings
|
|
112
|
+
.filter((binding) => binding.session_id === row.session_id)
|
|
113
|
+
.toSorted(
|
|
114
|
+
(left, right) =>
|
|
115
|
+
Date.parse(left.bound_at) - Date.parse(right.bound_at) ||
|
|
116
|
+
compareText(left.attempt_id, right.attempt_id),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const at = Date.parse(row.at);
|
|
120
|
+
const owner = bindings.findLast((binding) => Date.parse(binding.bound_at) <= at) ?? bindings[0];
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
owner === undefined ||
|
|
124
|
+
owner.caller_issuer === null ||
|
|
125
|
+
owner.caller_issuer !== row.origin_issuer ||
|
|
126
|
+
owner.caller_subject !== row.origin_subject
|
|
127
|
+
) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return owners.attempts.find((attempt) => attempt.id === owner.attempt_id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function compareText(left: string, right: string): number {
|
|
135
|
+
if (left === right) {
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return left < right ? -1 : 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// `reported` and `none` are the values of the telemetry table's `cost_basis`.
|
|
143
|
+
function pickRequestBasis(basis: string): 'reported' | 'none' {
|
|
144
|
+
if (basis === 'reported' || basis === 'none') {
|
|
145
|
+
return basis;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw new Error(
|
|
149
|
+
`a model request has the cost basis ${basis}, which the cost query does not know`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// phase, rework, product, model with a null model first, then the cost basis
|
|
154
|
+
function compareLines(left: CostLine, right: CostLine): number {
|
|
155
|
+
return (
|
|
156
|
+
compareText(left.phase, right.phase) ||
|
|
157
|
+
Number(left.rework) - Number(right.rework) ||
|
|
158
|
+
compareText(left.product, right.product) ||
|
|
159
|
+
compareText(left.model ?? '', right.model ?? '') ||
|
|
160
|
+
compareText(left.costBasis, right.costBasis)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// One `turn` line per phase, rework flag, product and model: a turn cost has no token count.
|
|
165
|
+
function collectTurnLines(owners: Owners, turns: readonly TurnReadRow[]): CostLine[] {
|
|
166
|
+
const lines = new Map<string, CostLine>();
|
|
167
|
+
|
|
168
|
+
for (const turn of turns) {
|
|
169
|
+
const attempt = findOwner(owners, turn);
|
|
170
|
+
|
|
171
|
+
if (attempt !== undefined) {
|
|
172
|
+
const rework = attempt.round > 1;
|
|
173
|
+
const key = JSON.stringify([attempt.phase, rework, turn.product, turn.model]);
|
|
174
|
+
const line = lines.get(key);
|
|
175
|
+
|
|
176
|
+
lines.set(key, {
|
|
177
|
+
phase: attempt.phase,
|
|
178
|
+
rework,
|
|
179
|
+
product: turn.product,
|
|
180
|
+
model: turn.model,
|
|
181
|
+
requests: 0,
|
|
182
|
+
inputTokens: 0,
|
|
183
|
+
cacheReadTokens: 0,
|
|
184
|
+
cacheCreationTokens: 0,
|
|
185
|
+
outputTokens: 0,
|
|
186
|
+
costUSD: (line?.costUSD ?? 0) + turn.cost_usd,
|
|
187
|
+
costBasis: 'turn',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return [...lines.values()].toSorted(compareLines);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The earliest merge of a pull request that an attempt of the item produced: when the item
|
|
196
|
+
// landed. null: it has not landed, and the lines are its cost so far.
|
|
197
|
+
function findLandedAt(pullRequests: readonly PullRequestReadRow[]): string | null {
|
|
198
|
+
const merges = pullRequests
|
|
199
|
+
.map((pullRequest) => pullRequest.merged_at)
|
|
200
|
+
.filter((mergedAt) => mergedAt !== null)
|
|
201
|
+
.toSorted((left, right) => Date.parse(left) - Date.parse(right));
|
|
202
|
+
|
|
203
|
+
return merges[0] ?? null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sumOf(values: readonly number[]): number {
|
|
207
|
+
return values.reduce((sum, value) => sum + value, 0);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The requests on `none` lines whose phase, rework flag and product have no `turn` line, of any
|
|
211
|
+
// model: their cost is in neither table, and above 0 the total is a lower bound.
|
|
212
|
+
function countRequestsWithoutCost(
|
|
213
|
+
requestLines: readonly CostLine[],
|
|
214
|
+
turnLines: readonly CostLine[],
|
|
215
|
+
): number {
|
|
216
|
+
const covered = new Set(
|
|
217
|
+
turnLines.map((line) => JSON.stringify([line.phase, line.rework, line.product])),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return sumOf(
|
|
221
|
+
requestLines
|
|
222
|
+
.filter((line) => line.costBasis === 'none')
|
|
223
|
+
.filter((line) => !covered.has(JSON.stringify([line.phase, line.rework, line.product])))
|
|
224
|
+
.map((line) => line.requests),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { stageTimeSchema } from '@boxxy/model';
|
|
2
|
+
import { defineQuery } from '@pepys/kit';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { boxxyWorkConsumer } from '../work/boxxy-work-consumer.ts';
|
|
5
|
+
import { buildStageTime } from './build-stage-time.ts';
|
|
6
|
+
import { findItemEntityID } from './find-item-entity-id.ts';
|
|
7
|
+
import type { DeliveryReads, StageTimeReads } from './types.ts';
|
|
8
|
+
|
|
9
|
+
// The stage-time query: the item's attempts, and the delivery times of each pull request an
|
|
10
|
+
// attempt produced. Two statements, so an item with no pull request still has its phase times.
|
|
11
|
+
export function buildStageTimeQuery(delivery: DeliveryReads) {
|
|
12
|
+
const reads: StageTimeReads = [boxxyWorkConsumer, ...delivery.consumers];
|
|
13
|
+
|
|
14
|
+
return defineQuery({
|
|
15
|
+
name: 'boxxy_stage_time',
|
|
16
|
+
description:
|
|
17
|
+
'Returns how long one boxxy item took in each of seven steps (triage, design, build, review, merge, release, deploy), its rework rounds and time, its wait for review and its sleep time. A step that has not happened has null minutes.',
|
|
18
|
+
input: z.object({
|
|
19
|
+
item: z.string().describe('The item, as `boxxy:item:<id>`'),
|
|
20
|
+
environment: z.string().describe('The environment whose deploy counts, such as `production`'),
|
|
21
|
+
}),
|
|
22
|
+
output: stageTimeSchema,
|
|
23
|
+
reads,
|
|
24
|
+
handler: async (call) => {
|
|
25
|
+
const item = findItemEntityID(call.input.item);
|
|
26
|
+
const parts = { item: call.input.item, environment: call.input.environment };
|
|
27
|
+
|
|
28
|
+
if (item === undefined) {
|
|
29
|
+
return buildStageTime({ ...parts, attempts: [], deliveries: [] });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const attempts = await call.db
|
|
33
|
+
.selectFrom('boxxy_attempt')
|
|
34
|
+
.where('item_id', '=', item)
|
|
35
|
+
.select([
|
|
36
|
+
'id',
|
|
37
|
+
'phase',
|
|
38
|
+
'number',
|
|
39
|
+
'round',
|
|
40
|
+
'state',
|
|
41
|
+
'outcome',
|
|
42
|
+
'failure',
|
|
43
|
+
'claimed_at',
|
|
44
|
+
'ended_at',
|
|
45
|
+
'sleeps',
|
|
46
|
+
])
|
|
47
|
+
.orderBy('number')
|
|
48
|
+
.orderBy('id')
|
|
49
|
+
.execute();
|
|
50
|
+
|
|
51
|
+
const deliveries = await delivery.readDeliveries(call.db, {
|
|
52
|
+
item,
|
|
53
|
+
environment: call.input.environment,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return buildStageTime({ ...parts, attempts, deliveries });
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { phaseSteps } from '@boxxy/model';
|
|
2
|
+
import type { StageTime } from '@boxxy/model';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { noCapacityFailure, outcomeNames } from './constants.ts';
|
|
5
|
+
import type { AttemptReadRow, DeliveryReadRow } from './types.ts';
|
|
6
|
+
|
|
7
|
+
interface StageParts {
|
|
8
|
+
readonly item: string;
|
|
9
|
+
readonly environment: string;
|
|
10
|
+
readonly attempts: readonly AttemptReadRow[];
|
|
11
|
+
readonly deliveries: readonly DeliveryReadRow[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// How long one item took in each step. A `no-capacity` attempt ran no session, so it is in no
|
|
15
|
+
// measure. Every other attempt counts by `ended_at` alone, a `usage-limit`, a lost and an
|
|
16
|
+
// unsettleable one too: the time was spent.
|
|
17
|
+
export function buildStageTime(parts: StageParts): StageTime {
|
|
18
|
+
const attempts = parts.attempts
|
|
19
|
+
.filter((attempt) => attempt.failure !== noCapacityFailure)
|
|
20
|
+
.map((attempt) => toMeasuredAttempt(attempt));
|
|
21
|
+
|
|
22
|
+
const settled = attempts.filter((attempt) => attempt.ended_at !== null);
|
|
23
|
+
|
|
24
|
+
const phases = phaseSteps.map((step) =>
|
|
25
|
+
buildPhaseStep(
|
|
26
|
+
step,
|
|
27
|
+
settled.filter((attempt) => attempt.phase === step),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
item: parts.item,
|
|
33
|
+
environment: parts.environment,
|
|
34
|
+
steps: [...phases, ...buildDeliverySteps(settled, parts.deliveries)],
|
|
35
|
+
rework: {
|
|
36
|
+
count: settled.filter(
|
|
37
|
+
(attempt) =>
|
|
38
|
+
attempt.phase === 'review' && attempt.outcome === outcomeNames.changesRequested,
|
|
39
|
+
).length,
|
|
40
|
+
minutes: sumOf(
|
|
41
|
+
settled
|
|
42
|
+
.filter((attempt) => attempt.round > 1)
|
|
43
|
+
.map((attempt) => measureAttemptMinutes(attempt)),
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
reviewWaitMinutes: measureReviewWait(attempts),
|
|
47
|
+
sleepMinutes: sumOf(
|
|
48
|
+
attempts.flatMap((attempt) => attempt.stretches.map((stretch) => measureStretch(stretch))),
|
|
49
|
+
),
|
|
50
|
+
pullRequests: [...new Set(parts.deliveries.map((delivery) => delivery.pullRequest))].toSorted(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SleepStretch {
|
|
55
|
+
readonly from: string;
|
|
56
|
+
readonly to: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface MeasuredAttempt extends AttemptReadRow {
|
|
60
|
+
readonly stretches: readonly SleepStretch[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toMeasuredAttempt(attempt: AttemptReadRow): MeasuredAttempt {
|
|
64
|
+
return { ...attempt, stretches: parseSleeps(attempt.sleeps) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sleepsSchema = z.array(z.object({ from: z.string(), to: z.string().nullable() }));
|
|
68
|
+
|
|
69
|
+
// A JSON column comes back as text from one store and as the parsed list from another.
|
|
70
|
+
function parseSleeps(sleeps: unknown): SleepStretch[] {
|
|
71
|
+
const value: unknown = typeof sleeps === 'string' ? JSON.parse(sleeps) : sleeps;
|
|
72
|
+
|
|
73
|
+
return sleepsSchema.parse(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Step = StageTime['steps'][number];
|
|
77
|
+
|
|
78
|
+
// A phase's minutes: the sum, over its settled attempts, of claim to end less the attempt's
|
|
79
|
+
// sleep. null when none has settled. Its end is the latest end of the phase.
|
|
80
|
+
function buildPhaseStep(
|
|
81
|
+
step: (typeof phaseSteps)[number],
|
|
82
|
+
settled: readonly MeasuredAttempt[],
|
|
83
|
+
): Step {
|
|
84
|
+
if (settled.length === 0) {
|
|
85
|
+
return { step, minutes: null, endedAt: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
step,
|
|
90
|
+
minutes: sumOf(settled.map((attempt) => measureAttemptMinutes(attempt))),
|
|
91
|
+
endedAt: findLatest(settled.map((attempt) => attempt.ended_at)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sumOf(values: readonly number[]): number {
|
|
96
|
+
return values.reduce((sum, value) => sum + value, 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function measureAttemptMinutes(attempt: MeasuredAttempt): number {
|
|
100
|
+
const sleptMinutes = sumOf(attempt.stretches.map((stretch) => measureStretch(stretch)));
|
|
101
|
+
|
|
102
|
+
return measureMinutes(attempt.claimed_at, attempt.ended_at) - sleptMinutes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// An open stretch counts 0: the query reads no clock.
|
|
106
|
+
function measureStretch(stretch: SleepStretch): number {
|
|
107
|
+
return measureMinutes(stretch.from, stretch.to);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function measureMinutes(from: string, to: string | null): number {
|
|
111
|
+
return to === null ? 0 : (Date.parse(to) - Date.parse(from)) / 60_000;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findLatest(times: readonly (string | null)[]): string | null {
|
|
115
|
+
return pickTime(times, (left, right) => left > right);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pickTime(
|
|
119
|
+
times: readonly (string | null)[],
|
|
120
|
+
isBetter: (left: number, right: number) => boolean,
|
|
121
|
+
): string | null {
|
|
122
|
+
let best: string | null = null;
|
|
123
|
+
|
|
124
|
+
for (const time of times) {
|
|
125
|
+
if (time !== null && (best === null || isBetter(Date.parse(time), Date.parse(best)))) {
|
|
126
|
+
best = time;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return best;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Merge starts at the latest end of a review that passed. A review that settled `unsettleable`
|
|
134
|
+
// starts no merge step: boxxy could not read whether it passed. Release and deploy start at the
|
|
135
|
+
// step before. An item with two pull requests takes the earliest of each time.
|
|
136
|
+
function buildDeliverySteps(
|
|
137
|
+
settled: readonly MeasuredAttempt[],
|
|
138
|
+
deliveries: readonly DeliveryReadRow[],
|
|
139
|
+
): Step[] {
|
|
140
|
+
const passed = findLatest(
|
|
141
|
+
settled
|
|
142
|
+
.filter((attempt) => attempt.phase === 'review' && attempt.outcome === outcomeNames.passed)
|
|
143
|
+
.map((attempt) => attempt.ended_at),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const mergedAt = findEarliest(deliveries.map((delivery) => delivery.mergedAt));
|
|
147
|
+
const releasedAt = findEarliest(deliveries.map((delivery) => delivery.release?.at ?? null));
|
|
148
|
+
const deployedAt = findEarliest(deliveries.map((delivery) => delivery.deploy?.at ?? null));
|
|
149
|
+
|
|
150
|
+
return [
|
|
151
|
+
buildDeliveryStep('merge', passed, mergedAt),
|
|
152
|
+
buildDeliveryStep('release', mergedAt, releasedAt),
|
|
153
|
+
buildDeliveryStep('deploy', releasedAt, deployedAt),
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function findEarliest(times: readonly (string | null)[]): string | null {
|
|
158
|
+
return pickTime(times, (left, right) => left < right);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildDeliveryStep(step: Step['step'], from: string | null, to: string | null): Step {
|
|
162
|
+
return {
|
|
163
|
+
step,
|
|
164
|
+
minutes: from === null || to === null ? null : Math.max(0, measureMinutes(from, to)),
|
|
165
|
+
endedAt: to,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface SettledAttempt extends MeasuredAttempt {
|
|
170
|
+
readonly ended_at: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
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.
|
|
176
|
+
function measureReviewWait(attempts: readonly MeasuredAttempt[]): number | null {
|
|
177
|
+
const waits = attempts
|
|
178
|
+
.filter((attempt): attempt is SettledAttempt => attempt.ended_at !== null)
|
|
179
|
+
.filter((build) => build.phase === 'build' && build.outcome === outcomeNames.built)
|
|
180
|
+
.flatMap((build) => {
|
|
181
|
+
const [review] = attempts
|
|
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
|
+
);
|
|
190
|
+
|
|
191
|
+
return review === undefined ? [] : [measureMinutes(build.ended_at, review.claimed_at)];
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return waits.length === 0 ? null : sumOf(waits);
|
|
195
|
+
}
|