iterate 0.2.7 → 0.3.0
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 +86 -76
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/iterate.js +11 -3
- package/dist/api-url-B6404M82.mjs +17 -0
- package/dist/api-url-B6404M82.mjs.map +1 -0
- package/dist/app-ref-BipL0feU.mjs +35 -0
- package/dist/app-ref-BipL0feU.mjs.map +1 -0
- package/dist/app-ref-C1CrgXqX.mjs +7 -0
- package/dist/app-ref-C1CrgXqX.mjs.map +1 -0
- package/dist/app-ref-DYai_om1.mjs +7 -0
- package/dist/app-ref-DYai_om1.mjs.map +1 -0
- package/dist/cli-D0c-pDL_.mjs +1010 -0
- package/dist/cli-D0c-pDL_.mjs.map +1 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.mjs +4 -0
- package/dist/cloudflare-BTm90gQ4.mjs +951 -0
- package/dist/cloudflare-BTm90gQ4.mjs.map +1 -0
- package/dist/contract-s4FW4eES.mjs +309 -0
- package/dist/contract-s4FW4eES.mjs.map +1 -0
- package/dist/document-review/index.d.ts +5 -0
- package/dist/document-review/types.d.ts +107 -0
- package/dist/document-review.mjs +7015 -0
- package/dist/document-review.mjs.map +1 -0
- package/dist/durable-object-processor-durability-CNsTjAJS.mjs +205 -0
- package/dist/durable-object-processor-durability-CNsTjAJS.mjs.map +1 -0
- package/dist/idempotency-DleloJNt.mjs +28 -0
- package/dist/idempotency-DleloJNt.mjs.map +1 -0
- package/dist/index.mjs +1 -1
- package/dist/itx/api-url.d.ts +6 -0
- package/dist/itx/itx-node-client.d.ts +65 -0
- package/dist/itx/itx-session.d.ts +215 -0
- package/dist/itx/owned-rpc-session.d.ts +14 -0
- package/dist/itx/query-client.d.ts +10 -0
- package/dist/itx-api.generated.d.ts +6195 -0
- package/dist/itx-session-sjud8GiT.mjs +534 -0
- package/dist/itx-session-sjud8GiT.mjs.map +1 -0
- package/dist/live-state-BJNqOwFw.mjs +299 -0
- package/dist/live-state-BJNqOwFw.mjs.map +1 -0
- package/dist/next/api.d.ts +479 -0
- package/dist/next/api.mjs +0 -0
- package/dist/next/app-server.d.ts +44 -0
- package/dist/next/app-server.mjs +479 -0
- package/dist/next/app-server.mjs.map +1 -0
- package/dist/next/app-session.d.ts +49 -0
- package/dist/next/app-session.mjs +238 -0
- package/dist/next/app-session.mjs.map +1 -0
- package/dist/next/app.d.ts +29 -0
- package/dist/next/app.mjs +141 -0
- package/dist/next/app.mjs.map +1 -0
- package/dist/next/client/live-state.d.ts +63 -0
- package/dist/next/client/oauth.d.ts +12 -0
- package/dist/next/client/react.d.ts +109 -0
- package/dist/next/client/socket.d.ts +6 -0
- package/dist/next/client.mjs +156 -0
- package/dist/next/client.mjs.map +1 -0
- package/dist/next/expression.d.ts +146 -0
- package/dist/next/expression.mjs +399 -0
- package/dist/next/expression.mjs.map +1 -0
- package/dist/next/lib.d.ts +56 -0
- package/dist/next/lib.mjs +199 -0
- package/dist/next/lib.mjs.map +1 -0
- package/dist/next/oauth-scopes.d.ts +32 -0
- package/dist/next/oauth-scopes.mjs +40 -0
- package/dist/next/oauth-scopes.mjs.map +1 -0
- package/dist/next/oauth.mjs +29 -0
- package/dist/next/oauth.mjs.map +1 -0
- package/dist/next/principal.d.ts +64 -0
- package/dist/next/principal.mjs +98 -0
- package/dist/next/principal.mjs.map +1 -0
- package/dist/next/project-ingress.d.ts +37 -0
- package/dist/next/project-ingress.mjs +75 -0
- package/dist/next/project-ingress.mjs.map +1 -0
- package/dist/next/react.mjs +285 -0
- package/dist/next/react.mjs.map +1 -0
- package/dist/next/sdk/auth.d.ts +5 -0
- package/dist/next/sdk/index.d.ts +112 -0
- package/dist/next/sdk.mjs +139 -0
- package/dist/next/sdk.mjs.map +1 -0
- package/dist/next/stream/processor.d.ts +378 -0
- package/dist/next/stream/processor.mjs +582 -0
- package/dist/next/stream/processor.mjs.map +1 -0
- package/dist/next/stream/run.d.ts +58 -0
- package/dist/next/stream/run.mjs +40 -0
- package/dist/next/stream/run.mjs.map +1 -0
- package/dist/next-node.d.ts +15 -0
- package/dist/next-node.mjs +51 -0
- package/dist/next-node.mjs.map +1 -0
- package/dist/node.d.ts +3 -0
- package/dist/node.mjs +185 -0
- package/dist/node.mjs.map +1 -0
- package/dist/processor-host-capabilities-BMFH3KTM.mjs +56 -0
- package/dist/processor-host-capabilities-BMFH3KTM.mjs.map +1 -0
- package/dist/processors/cloudflare.d.ts +3 -0
- package/dist/processors/durable-object-processor-durability.d.ts +79 -0
- package/dist/processors/event-consumption-metrics.d.ts +82 -0
- package/dist/processors/idempotency.d.ts +13 -0
- package/dist/processors/index.d.ts +12 -0
- package/dist/processors/processor-contracts.d.ts +342 -0
- package/dist/processors/processor-facet.d.ts +186 -0
- package/dist/processors/processor-host-capabilities.d.ts +60 -0
- package/dist/processors/prompt-sections.d.ts +17 -0
- package/dist/processors/rpc-types.d.ts +515 -0
- package/dist/processors/schemas.d.ts +102 -0
- package/dist/processors/stream-handle.d.ts +45 -0
- package/dist/processors/stream-processor-keepalive.d.ts +95 -0
- package/dist/processors/stream-processor-registry.d.ts +233 -0
- package/dist/processors/stream-processor-runner.d.ts +289 -0
- package/dist/processors/stream-processor.d.ts +339 -0
- package/dist/processors/stream-runtime-metrics.d.ts +107 -0
- package/dist/processors/testing.d.ts +302 -0
- package/dist/processors-BoNyeBfQ.mjs +10 -0
- package/dist/processors-BoNyeBfQ.mjs.map +1 -0
- package/dist/processors-cloudflare.mjs +3 -0
- package/dist/processors-testing.mjs +435 -0
- package/dist/processors-testing.mjs.map +1 -0
- package/dist/processors.mjs +52 -0
- package/dist/processors.mjs.map +1 -0
- package/dist/protocol-DnK_f2m6.mjs +251 -0
- package/dist/protocol-DnK_f2m6.mjs.map +1 -0
- package/dist/sdk/capnweb/index.d.ts +2 -0
- package/dist/sdk/capnweb/live-state/compact.d.ts +5 -0
- package/dist/sdk/capnweb/live-state/diff.d.ts +41 -0
- package/dist/sdk/capnweb/live-state/engine.d.ts +44 -0
- package/dist/sdk/capnweb/live-state/index.d.ts +41 -0
- package/dist/sdk/capnweb/live-state/protocol.d.ts +87 -0
- package/dist/sdk/capnweb/live-state/retain.d.ts +23 -0
- package/dist/sdk/capnweb/live-state/store.d.ts +20 -0
- package/dist/sdk/capnweb/live-state/types.d.ts +11 -0
- package/dist/sdk/capnweb/react.d.ts +45 -0
- package/dist/sdk/capnweb/react.mjs +316 -0
- package/dist/sdk/capnweb/react.mjs.map +1 -0
- package/dist/sdk/capnweb.mjs +4 -0
- package/dist/sdk/itx/react.d.ts +191 -0
- package/dist/sdk/itx/react.mjs +383 -0
- package/dist/sdk/itx/react.mjs.map +1 -0
- package/dist/sdk-DMB-IM11.mjs +933 -0
- package/dist/sdk-DMB-IM11.mjs.map +1 -0
- package/dist/sdk.d.ts +339 -0
- package/dist/sdk.mjs +2 -0
- package/dist/serve-itx.d.ts +46 -0
- package/dist/starter-apps/flake-dashboard/app-ref.d.ts +31 -0
- package/dist/starter-apps/flake-dashboard/configured-worker.mjs +1055 -0
- package/dist/starter-apps/flake-dashboard/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/flake-dashboard/contract.d.ts +4839 -0
- package/dist/starter-apps/flake-dashboard/contract.mjs +2 -0
- package/dist/starter-apps/flake-dashboard/index.d.ts +17 -0
- package/dist/starter-apps/flake-dashboard/index.mjs +56 -0
- package/dist/starter-apps/flake-dashboard/index.mjs.map +1 -0
- package/dist/starter-apps/flake-dashboard/worker.d.ts +4607 -0
- package/dist/starter-apps/github-ai-linter/ai-linter.d.ts +8914 -0
- package/dist/starter-apps/github-ai-linter/configured-worker.mjs +17987 -0
- package/dist/starter-apps/github-ai-linter/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/github-ai-linter/contract.d.ts +9193 -0
- package/dist/starter-apps/github-ai-linter/index.d.ts +10 -0
- package/dist/starter-apps/github-ai-linter/index.mjs +36 -0
- package/dist/starter-apps/github-ai-linter/index.mjs.map +1 -0
- package/dist/starter-apps/github-ai-linter/prompt.d.ts +13 -0
- package/dist/starter-apps/github-ai-linter/review-bot.d.ts +808 -0
- package/dist/starter-apps/github-ai-linter/rules.d.ts +34 -0
- package/dist/starter-apps/github-ai-linter/worker-ref.d.ts +19 -0
- package/dist/starter-apps/github-ai-linter/worker.d.ts +19 -0
- package/dist/starter-apps/github-ai-linter/worker.mjs +947 -0
- package/dist/starter-apps/github-ai-linter/worker.mjs.map +1 -0
- package/dist/starter-apps/guestbook/app-ref.d.ts +27 -0
- package/dist/starter-apps/guestbook/client.d.ts +7 -0
- package/dist/starter-apps/guestbook/client.mjs +59 -0
- package/dist/starter-apps/guestbook/configured-worker.mjs +205 -0
- package/dist/starter-apps/guestbook/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/guestbook/index.d.ts +9 -0
- package/dist/starter-apps/guestbook/index.mjs +31 -0
- package/dist/starter-apps/guestbook/index.mjs.map +1 -0
- package/dist/starter-apps/guestbook/processor.d.ts +2267 -0
- package/dist/starter-apps/guestbook/worker.d.ts +26 -0
- package/dist/starter-apps/guestbook/worker.mjs +191 -0
- package/dist/starter-apps/guestbook/worker.mjs.map +1 -0
- package/dist/starter-apps/media/configured-worker.mjs +577 -0
- package/dist/starter-apps/media/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/media/index.mjs +36 -0
- package/dist/starter-apps/media/index.mjs.map +1 -0
- package/dist/starter-apps/media/ref.mjs +20 -0
- package/dist/starter-apps/media/ref.mjs.map +1 -0
- package/dist/starter-apps/media/worker.mjs +579 -0
- package/dist/starter-apps/media/worker.mjs.map +1 -0
- package/dist/starter-apps/notes/configured-worker.mjs +6134 -0
- package/dist/starter-apps/notes/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/notes/index.mjs +23 -0
- package/dist/starter-apps/notes/index.mjs.map +1 -0
- package/dist/starter-apps/notes/ref.mjs +21 -0
- package/dist/starter-apps/notes/ref.mjs.map +1 -0
- package/dist/starter-apps/notes/worker.mjs +427 -0
- package/dist/starter-apps/notes/worker.mjs.map +1 -0
- package/dist/starter-apps/todo/client.mjs +59 -0
- package/dist/starter-apps/todo/configured-worker.mjs +2864 -0
- package/dist/starter-apps/todo/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/todo/index.d.ts +8 -0
- package/dist/starter-apps/todo/index.mjs +29 -0
- package/dist/starter-apps/todo/index.mjs.map +1 -0
- package/dist/stream-processor-keepalive-DAQTP6m3.mjs +2082 -0
- package/dist/stream-processor-keepalive-DAQTP6m3.mjs.map +1 -0
- package/dist/usingCtx-inzbY1Qz.mjs +57 -0
- package/dist/usingCtx-mZx5nsAW.mjs +11800 -0
- package/dist/usingCtx-mZx5nsAW.mjs.map +1 -0
- package/dist/worker-ref-DZxPDmb_.mjs +390 -0
- package/dist/worker-ref-DZxPDmb_.mjs.map +1 -0
- package/menubar/Iterate.entitlements +12 -0
- package/menubar/Iterate.swift +914 -0
- package/menubar/IterateIcon.swift +145 -0
- package/menubar/README.md +28 -0
- package/menubar/build-menubar-app.sh +59 -0
- package/package.json +235 -18
- package/dist/cli-DMS4kJph.mjs +0 -868
- package/dist/cli-DMS4kJph.mjs.map +0 -1
- package/dist/config-DtnR7Lv7.mjs +0 -170
- package/dist/config-DtnR7Lv7.mjs.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/stream-tui/agent-chat-terminal.d.mts +0 -1
- package/dist/stream-tui/agent-chat-terminal.mjs +0 -933
- package/dist/stream-tui/agent-chat-terminal.mjs.map +0 -1
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
import { _ as object, b as url, d as _enum, f as array, g as number, h as literal, l as defineProcessorContract, m as discriminatedUnion, o as StreamProcessor, r as StreamProcessorDurableObject, t as _usingCtx, u as ZodISODateTime, v as record, x as _isoDateTime, y as string } from "../../usingCtx-mZx5nsAW.mjs";
|
|
2
|
+
import { t as isIdempotencyConflict } from "../../processors-BoNyeBfQ.mjs";
|
|
3
|
+
//#region ../../node_modules/.pnpm/zod@4.5.4_patch_hash=bf04bb1ec1f0b9f31a7416bf98f83d3451e5011852877ca0ac19c12fcb3129b5/node_modules/zod/v4/classic/iso.js
|
|
4
|
+
function datetime(params) {
|
|
5
|
+
return _isoDateTime(ZodISODateTime, params);
|
|
6
|
+
}
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/starter-apps/flake-dashboard/app-ref.ts
|
|
9
|
+
const flakesStreamPath = "/flakes";
|
|
10
|
+
/**
|
|
11
|
+
* The dashboard's birth certificate. Idempotency-keyed so every caller — the
|
|
12
|
+
* worker's lazy initialization AND the CI reporter script, whichever appends
|
|
13
|
+
* first — may offer it; the config values are immutable birth facts.
|
|
14
|
+
*/
|
|
15
|
+
function flakeDashboardCreationEvents(config) {
|
|
16
|
+
return [{
|
|
17
|
+
type: "events.iterate.com/flakes/created",
|
|
18
|
+
payload: { config },
|
|
19
|
+
idempotencyKey: "flakes/created"
|
|
20
|
+
}];
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region ../shared/src/test-support/flake-suite-summary.ts
|
|
24
|
+
/** A full CI suite publishes this even when its flake record list is empty. */
|
|
25
|
+
const FlakeSuiteSummary = object({
|
|
26
|
+
headSha: string().min(1),
|
|
27
|
+
branch: string(),
|
|
28
|
+
status: _enum(["complete", "incomplete"]),
|
|
29
|
+
startedAt: datetime(),
|
|
30
|
+
finishedAt: datetime(),
|
|
31
|
+
testCount: number().int().nonnegative(),
|
|
32
|
+
tests: array(object({
|
|
33
|
+
name: string().min(1),
|
|
34
|
+
outcome: _enum([
|
|
35
|
+
"pass",
|
|
36
|
+
"fail",
|
|
37
|
+
"skip"
|
|
38
|
+
])
|
|
39
|
+
})).optional(),
|
|
40
|
+
unknownFlakeCount: number().int().nonnegative(),
|
|
41
|
+
failedCount: number().int().nonnegative(),
|
|
42
|
+
diagnostics: array(string()),
|
|
43
|
+
runUrl: url()
|
|
44
|
+
}).refine((summary) => summary.status !== "complete" || summary.testCount > 0 && !!summary.branch && summary.diagnostics.length === 0, "A complete suite needs executed tests and no missing-result diagnostics");
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/starter-apps/flake-dashboard/contract.ts
|
|
47
|
+
const flakeEventTypes = {
|
|
48
|
+
created: "events.iterate.com/flakes/created",
|
|
49
|
+
runRecorded: "events.iterate.com/flakes/run-recorded",
|
|
50
|
+
transitionProposed: "events.iterate.com/flakes/transition-proposed",
|
|
51
|
+
dashboardRenderSettled: "events.iterate.com/flakes/dashboard-render-settled"
|
|
52
|
+
};
|
|
53
|
+
const StreamOffset = number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
54
|
+
/**
|
|
55
|
+
* One `createFlake` test outcome. Mirrors `FlakeRecord` in
|
|
56
|
+
* `@iterate-com/shared/test-support/flake-test` — the wrapper writes these
|
|
57
|
+
* lines to `FLAKE_RECORD_DIR` and CI ships them here verbatim.
|
|
58
|
+
*/
|
|
59
|
+
const FlakeOutcome = _enum([
|
|
60
|
+
"pass",
|
|
61
|
+
"flake-fail",
|
|
62
|
+
"unexpected-error",
|
|
63
|
+
"pinned-fail",
|
|
64
|
+
"unexpected-pass",
|
|
65
|
+
"retried-pass"
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* Which wrapper (or reporter) produced a record: createFlake, createFailing,
|
|
69
|
+
* or the telemetry reporters' retried-pass records for tests nobody has
|
|
70
|
+
* classified yet. Old events predate the field — they were all createFlake.
|
|
71
|
+
*/
|
|
72
|
+
const FlakeKind = _enum([
|
|
73
|
+
"flake",
|
|
74
|
+
"failing",
|
|
75
|
+
"unknown"
|
|
76
|
+
]);
|
|
77
|
+
const FlakeRecord = object({
|
|
78
|
+
name: string().min(1).max(1e3),
|
|
79
|
+
kind: FlakeKind.default("flake"),
|
|
80
|
+
outcome: FlakeOutcome,
|
|
81
|
+
pattern: string().min(1).max(2e3).optional(),
|
|
82
|
+
durationMs: number().nonnegative(),
|
|
83
|
+
at: string().min(1),
|
|
84
|
+
error: string().max(4e3).optional()
|
|
85
|
+
});
|
|
86
|
+
const FlakeDashboardConfig = object({
|
|
87
|
+
repository: object({
|
|
88
|
+
owner: string().min(1),
|
|
89
|
+
repo: string().min(1)
|
|
90
|
+
}),
|
|
91
|
+
issueTitle: string().min(1).max(200),
|
|
92
|
+
defaultBranch: string().min(1)
|
|
93
|
+
});
|
|
94
|
+
const FlakeRunRecorded = object({
|
|
95
|
+
runId: string().min(1).max(200),
|
|
96
|
+
suite: string().min(1).max(200),
|
|
97
|
+
branch: string().min(1).max(500),
|
|
98
|
+
commit: string().min(1).max(100),
|
|
99
|
+
records: array(FlakeRecord).max(1e4),
|
|
100
|
+
summary: FlakeSuiteSummary.optional()
|
|
101
|
+
});
|
|
102
|
+
const MainSuiteRun = object({
|
|
103
|
+
runId: string(),
|
|
104
|
+
commit: string(),
|
|
105
|
+
summary: FlakeSuiteSummary
|
|
106
|
+
});
|
|
107
|
+
const FlakeTransition = _enum([
|
|
108
|
+
"unwrap",
|
|
109
|
+
"switch-to-failing",
|
|
110
|
+
"unwrap-failing"
|
|
111
|
+
]);
|
|
112
|
+
const FlakeTransitionProposed = object({
|
|
113
|
+
testName: string().min(1),
|
|
114
|
+
transition: FlakeTransition,
|
|
115
|
+
evidence: object({
|
|
116
|
+
consecutiveRuns: number().int().positive(),
|
|
117
|
+
firstAt: string().min(1),
|
|
118
|
+
lastAt: string().min(1)
|
|
119
|
+
})
|
|
120
|
+
});
|
|
121
|
+
const FlakeDashboardRenderResult = discriminatedUnion("status", [object({
|
|
122
|
+
status: literal("succeeded"),
|
|
123
|
+
issueNumber: number().int().positive(),
|
|
124
|
+
issueUrl: string().url()
|
|
125
|
+
}), object({
|
|
126
|
+
status: literal("failed"),
|
|
127
|
+
error: string().min(1).max(8e3)
|
|
128
|
+
})]);
|
|
129
|
+
/**
|
|
130
|
+
* A run of consecutive same-outcome recorded runs on the default branch.
|
|
131
|
+
* `unexpected-error` outcomes and outcome changes reset it. The streak is the
|
|
132
|
+
* whole input to transition proposals, so it lives in reduced state rather
|
|
133
|
+
* than being recomputed from history.
|
|
134
|
+
*/
|
|
135
|
+
const DefaultBranchStreak = object({
|
|
136
|
+
outcome: FlakeOutcome,
|
|
137
|
+
runs: number().int().positive(),
|
|
138
|
+
firstAt: string().min(1),
|
|
139
|
+
lastAt: string().min(1)
|
|
140
|
+
});
|
|
141
|
+
const TrackedTest = object({
|
|
142
|
+
/** Latest record's kind wins: adopting an unknown flake into createFlake migrates its row. */
|
|
143
|
+
kind: FlakeKind.default("flake"),
|
|
144
|
+
/** Empty for kind "unknown" — those rows show error samples instead. */
|
|
145
|
+
pattern: string().default(""),
|
|
146
|
+
suites: array(string()).default([]),
|
|
147
|
+
/**
|
|
148
|
+
* Per suite, the offset of the newest run-recorded event (any branch) whose
|
|
149
|
+
* records included this test. Compared against the suite's
|
|
150
|
+
* `recentRunOffsets` window at render time: a test absent from all of its
|
|
151
|
+
* suite's last few ingested runs is retired from the table — hidden, not
|
|
152
|
+
* deleted, so a transient absence self-heals on the next record.
|
|
153
|
+
*/
|
|
154
|
+
lastSeenOffset: record(string(), StreamOffset).default({}),
|
|
155
|
+
/**
|
|
156
|
+
* The latest (up to) 10 outcomes on any branch, ordered by test time — the
|
|
157
|
+
* render's emoji streak bar, each entry carrying the commit that produced
|
|
158
|
+
* it so the square can link straight to that commit's checks. All branches,
|
|
159
|
+
* like the counts, for debugging PR failures too. The numeric
|
|
160
|
+
* defaultBranchStreak below stays main-only and carries the
|
|
161
|
+
* transition-threshold counts past what 10 entries can show.
|
|
162
|
+
*/
|
|
163
|
+
recent: array(object({
|
|
164
|
+
outcome: FlakeOutcome,
|
|
165
|
+
commit: string().min(1).max(100),
|
|
166
|
+
at: string()
|
|
167
|
+
})).max(10).default([]),
|
|
168
|
+
/** Keyed by outcome value ("pass", "pinned-fail", …). */
|
|
169
|
+
counts: record(string(), number().int().nonnegative()).default({}),
|
|
170
|
+
/**
|
|
171
|
+
* The last few error samples worth showing (retried-pass and
|
|
172
|
+
* unexpected-error records) — for unknown flakes these are the copy-paste
|
|
173
|
+
* material for the createFlake pattern.
|
|
174
|
+
*/
|
|
175
|
+
recentErrors: array(object({
|
|
176
|
+
error: string().max(4e3),
|
|
177
|
+
commit: string(),
|
|
178
|
+
at: string()
|
|
179
|
+
})).max(3).default([]),
|
|
180
|
+
lastFlakeAt: string().nullable().default(null),
|
|
181
|
+
firstRecordedAt: string(),
|
|
182
|
+
lastRecordedAt: string(),
|
|
183
|
+
defaultBranchStreak: DefaultBranchStreak.nullable().default(null),
|
|
184
|
+
/** Older retry evidence cannot undo a wrapper observed on main in this suite. */
|
|
185
|
+
lastMainWrapperAt: record(string(), string()).default({}),
|
|
186
|
+
/**
|
|
187
|
+
* One entry per proposal already made, keyed `${transition}:${streak.firstAt}`
|
|
188
|
+
* — a streak proposes at most once however long it grows.
|
|
189
|
+
*/
|
|
190
|
+
proposed: array(string()).default([])
|
|
191
|
+
});
|
|
192
|
+
const FlakeDashboardState = object({
|
|
193
|
+
birthCertificate: object({ config: FlakeDashboardConfig }).nullable().default(null),
|
|
194
|
+
tests: record(string(), TrackedTest).default({}),
|
|
195
|
+
/**
|
|
196
|
+
* Main-only unknowns, isolated by suite. Healthy rows are hidden, retaining
|
|
197
|
+
* their streak so late failures can correct an apparent recovery.
|
|
198
|
+
*/
|
|
199
|
+
unknownFlakes: record(string(), record(string(), object({
|
|
200
|
+
record: FlakeRecord,
|
|
201
|
+
passStreak: number().int().nonnegative(),
|
|
202
|
+
/** Newest run that actually contained this test, including skips. */
|
|
203
|
+
lastRunAt: string(),
|
|
204
|
+
recent: array(object({
|
|
205
|
+
outcome: FlakeOutcome,
|
|
206
|
+
commit: string(),
|
|
207
|
+
at: string()
|
|
208
|
+
})).max(10)
|
|
209
|
+
}))).default({}),
|
|
210
|
+
mainRuns: record(string(), object({
|
|
211
|
+
latest: MainSuiteRun,
|
|
212
|
+
complete: MainSuiteRun.nullable(),
|
|
213
|
+
inventory: object({
|
|
214
|
+
startedAt: datetime(),
|
|
215
|
+
names: array(string())
|
|
216
|
+
}).nullable().default(null)
|
|
217
|
+
})).default({}),
|
|
218
|
+
/**
|
|
219
|
+
* The newest three ingested results across all branches retire absent
|
|
220
|
+
* tracked tests. This preserves visibility during partial PR runs. Unknown
|
|
221
|
+
* flakes retire after 20 main passes, wrapper adoption, or proven absence
|
|
222
|
+
* from their suite's full main inventory.
|
|
223
|
+
*/
|
|
224
|
+
suites: record(string(), object({ recentRunOffsets: array(StreamOffset).max(3).default([]) })).default({}),
|
|
225
|
+
/**
|
|
226
|
+
* Offset of the newest reduced DATA event (created / run-recorded /
|
|
227
|
+
* transition-proposed). Render settlements deliberately do not bump it —
|
|
228
|
+
* that is what stops a settled render from demanding another render.
|
|
229
|
+
*/
|
|
230
|
+
lastDataOffset: StreamOffset.default(0),
|
|
231
|
+
render: object({
|
|
232
|
+
throughOffset: StreamOffset,
|
|
233
|
+
issueNumber: number().int().positive().nullable(),
|
|
234
|
+
issueUrl: string().nullable()
|
|
235
|
+
}).nullable().default(null)
|
|
236
|
+
});
|
|
237
|
+
/**
|
|
238
|
+
* Suggest unwrapping createFlake after 20 consecutive main passes, regardless
|
|
239
|
+
* of elapsed time. Unknown flakes retire automatically at the same count.
|
|
240
|
+
* Propose createFailing after 25 matched failures over >=2 days.
|
|
241
|
+
* Tunable constants. Sentinel tests are excluded from proposals entirely —
|
|
242
|
+
* they are designed to flake.
|
|
243
|
+
*/
|
|
244
|
+
const flakeTransitionThresholds = {
|
|
245
|
+
unwrap: {
|
|
246
|
+
runs: 20,
|
|
247
|
+
minSpanMs: 0
|
|
248
|
+
},
|
|
249
|
+
"switch-to-failing": {
|
|
250
|
+
runs: 25,
|
|
251
|
+
minSpanMs: 2880 * 60 * 1e3
|
|
252
|
+
},
|
|
253
|
+
"unwrap-failing": {
|
|
254
|
+
runs: 10,
|
|
255
|
+
minSpanMs: 2880 * 60 * 1e3
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* The exact shape of a delivered event this app ingests: a platform-verified
|
|
260
|
+
* GitHub webhook for a COMPLETED check_run on a connection stream. This
|
|
261
|
+
* repo's test suites run on Depot CI, whose jobs are GitHub check runs (NOT
|
|
262
|
+
* GitHub Actions workflow runs — `workflow_run` never fires for them). Zod
|
|
263
|
+
* strips the rest of GitHub's (huge) payload; only these fields are read.
|
|
264
|
+
*/
|
|
265
|
+
const CheckRunWebhookEvent = object({
|
|
266
|
+
type: literal("events.iterate.com/github/webhook-received"),
|
|
267
|
+
path: string().regex(/^\/integrations\/github\/[^/]+$/),
|
|
268
|
+
payload: object({
|
|
269
|
+
delivery: object({ name: literal("check_run") }),
|
|
270
|
+
body: object({
|
|
271
|
+
action: literal("completed"),
|
|
272
|
+
check_run: object({
|
|
273
|
+
conclusion: _enum(["success", "failure"]),
|
|
274
|
+
head_sha: string().min(1),
|
|
275
|
+
check_suite: object({ head_branch: string().nullish() }).optional()
|
|
276
|
+
}),
|
|
277
|
+
repository: object({
|
|
278
|
+
name: string().min(1),
|
|
279
|
+
owner: object({ login: string().min(1) }),
|
|
280
|
+
default_branch: string().nullish()
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
});
|
|
285
|
+
const FlakeDashboardProcessorContract = defineProcessorContract({
|
|
286
|
+
slug: "flake-dashboard",
|
|
287
|
+
version: "0.8.0",
|
|
288
|
+
description: "Folds createFlake test outcomes reported by CI into per-test flake stats, renders the GitHub 'Flake dashboard' issue, and proposes data-provable lifecycle transitions.",
|
|
289
|
+
stateSchema: FlakeDashboardState,
|
|
290
|
+
events: {
|
|
291
|
+
[flakeEventTypes.created]: {
|
|
292
|
+
description: "The flake dashboard exists: target repository, issue title, and which branch counts for transitions.",
|
|
293
|
+
payloadSchema: object({ config: FlakeDashboardConfig }),
|
|
294
|
+
examples: [{
|
|
295
|
+
description: "The dogfood dashboard for this repo.",
|
|
296
|
+
payload: { config: {
|
|
297
|
+
repository: {
|
|
298
|
+
owner: "iterate",
|
|
299
|
+
repo: "iterate"
|
|
300
|
+
},
|
|
301
|
+
issueTitle: "Flake dashboard",
|
|
302
|
+
defaultBranch: "main"
|
|
303
|
+
} }
|
|
304
|
+
}]
|
|
305
|
+
},
|
|
306
|
+
[flakeEventTypes.runRecorded]: {
|
|
307
|
+
description: "One CI run+suite's createFlake outcomes, appended by the CI reporter script (never by this processor). Idempotency-keyed on run+suite so CI retries cannot double-count.",
|
|
308
|
+
payloadSchema: FlakeRunRecorded
|
|
309
|
+
},
|
|
310
|
+
[flakeEventTypes.transitionProposed]: {
|
|
311
|
+
description: "A test's default-branch streak crossed a lifecycle threshold; downstream automation (an agent) should open the unwrap / switch-to-failing PR. The file-edit guard (a streak is only trustworthy if the test file did not change during it) is applied by that agent, which can read git history — the fold cannot.",
|
|
312
|
+
payloadSchema: FlakeTransitionProposed
|
|
313
|
+
},
|
|
314
|
+
[flakeEventTypes.dashboardRenderSettled]: {
|
|
315
|
+
description: "Terminal fact for one dashboard render attempt: the GitHub issue reached (or the error), and the data offset it covered.",
|
|
316
|
+
payloadSchema: object({
|
|
317
|
+
throughOffset: StreamOffset,
|
|
318
|
+
result: FlakeDashboardRenderResult
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
consumes: [...Object.values(flakeEventTypes)],
|
|
323
|
+
emits: [flakeEventTypes.transitionProposed, flakeEventTypes.dashboardRenderSettled]
|
|
324
|
+
});
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/starter-apps/flake-dashboard/worker.ts
|
|
327
|
+
const ARTIFACT_PREFIX = "flake-records-";
|
|
328
|
+
const MAX_ARTIFACT_BYTES = 5 * 1024 * 1024;
|
|
329
|
+
/**
|
|
330
|
+
* The whole app is this worker: everything is processEvent and reduce.
|
|
331
|
+
* `FlakeDashboardApp.processEvent` ingests workflow_run webhooks (CI's
|
|
332
|
+
* credential-free reporting signal) into run-recorded events and wakes the
|
|
333
|
+
* processor for /flakes events; `FlakeDashboardProcessor.reduce` folds them;
|
|
334
|
+
* the processor's own processEvent drives the issue render and transition
|
|
335
|
+
* proposals. No HTTP is served.
|
|
336
|
+
*/
|
|
337
|
+
var FlakeDashboardApp = class extends StreamProcessorDurableObject {
|
|
338
|
+
streamPath = flakesStreamPath;
|
|
339
|
+
recovery = true;
|
|
340
|
+
createProcessor(deps) {
|
|
341
|
+
return new FlakeDashboardProcessor({
|
|
342
|
+
...deps,
|
|
343
|
+
renderDashboard: async (state) => {
|
|
344
|
+
try {
|
|
345
|
+
var _usingCtx$1 = _usingCtx();
|
|
346
|
+
return await renderFlakeDashboardIssue(_usingCtx$1.u(await this.env.ITX.get()), state);
|
|
347
|
+
} catch (_) {
|
|
348
|
+
_usingCtx$1.e = _;
|
|
349
|
+
} finally {
|
|
350
|
+
_usingCtx$1.d();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Every flake-relevant event lands here — the same override point the
|
|
357
|
+
* platform's own delivery uses (widened public so the config worker can
|
|
358
|
+
* forward events over RPC). A /flakes event is a wake hint: catch-up
|
|
359
|
+
* re-reads committed events from the stream and owns validation, ordering,
|
|
360
|
+
* checkpointing, and dedupe.
|
|
361
|
+
*/
|
|
362
|
+
async processEvent(event, options) {
|
|
363
|
+
const webhook = CheckRunWebhookEvent.safeParse(event);
|
|
364
|
+
if (webhook.success) {
|
|
365
|
+
if (!options?.depot) return;
|
|
366
|
+
try {
|
|
367
|
+
await this.#ingestCheckRunFlakeArtifacts(webhook.data, options.depot);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.error(`[flake-ingest] ingestion failed for sha ${webhook.data.payload.body.check_run.head_sha} (records dropped):`, error);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (event.path !== "/flakes") return;
|
|
374
|
+
await (await this.registry()).catchUp("flake-dashboard");
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Artifact-pull ingestion for flake telemetry. This repo's suites run on
|
|
378
|
+
* Depot CI, so a completed check_run is the signal: resolve the Depot run
|
|
379
|
+
* by commit sha, list its `flake-records-<suite>` artifacts, fetch each
|
|
380
|
+
* via a short-lived signed URL, and append one run-recorded event per
|
|
381
|
+
* artifact to /flakes. CI holds no iterate credential; the Depot read
|
|
382
|
+
* token is a platform-held project secret. Any check_run completing on the
|
|
383
|
+
* same Depot run re-scans it — repeated appends dedupe on the
|
|
384
|
+
* run+attempt+suite idempotency key, so redeliveries and sibling checks
|
|
385
|
+
* are harmless.
|
|
386
|
+
*/
|
|
387
|
+
async #ingestCheckRunFlakeArtifacts(webhook, depot) {
|
|
388
|
+
try {
|
|
389
|
+
var _usingCtx3 = _usingCtx();
|
|
390
|
+
const { check_run: checkRun, repository } = webhook.payload.body;
|
|
391
|
+
const params = {
|
|
392
|
+
owner: repository.owner.login,
|
|
393
|
+
repo: repository.name
|
|
394
|
+
};
|
|
395
|
+
const itx = _usingCtx3.u(await this.env.ITX.get());
|
|
396
|
+
const token = await itx.secrets.get(depot.secretPath).reveal();
|
|
397
|
+
const depotRpc = async (method, body) => {
|
|
398
|
+
const response = await fetch(`https://api.depot.dev/depot.ci.v1.CIService/${method}`, {
|
|
399
|
+
method: "POST",
|
|
400
|
+
headers: {
|
|
401
|
+
"content-type": "application/json",
|
|
402
|
+
authorization: `Bearer ${token}`,
|
|
403
|
+
"x-depot-org": depot.orgId
|
|
404
|
+
},
|
|
405
|
+
body: JSON.stringify(body)
|
|
406
|
+
});
|
|
407
|
+
if (!response.ok) throw new Error(`depot ${method} failed: HTTP ${response.status} ${await response.text()}`);
|
|
408
|
+
return await response.json();
|
|
409
|
+
};
|
|
410
|
+
const runs = await depotRpc("ListRuns", {
|
|
411
|
+
repo: `${params.owner}/${params.repo}`,
|
|
412
|
+
sha: checkRun.head_sha,
|
|
413
|
+
pageSize: 10
|
|
414
|
+
});
|
|
415
|
+
for (const run of runs.runs || []) {
|
|
416
|
+
const flakeArtifacts = ((await depotRpc("ListArtifacts", {
|
|
417
|
+
runId: run.runId,
|
|
418
|
+
pageSize: 100
|
|
419
|
+
})).artifacts || []).filter((artifact) => artifact.name.startsWith(ARTIFACT_PREFIX));
|
|
420
|
+
if (flakeArtifacts.length === 0) continue;
|
|
421
|
+
const stream = itx.streams.get(flakesStreamPath);
|
|
422
|
+
try {
|
|
423
|
+
await stream.append(...flakeDashboardCreationEvents({
|
|
424
|
+
repository: params,
|
|
425
|
+
issueTitle: "Flake dashboard",
|
|
426
|
+
defaultBranch: repository.default_branch || "main"
|
|
427
|
+
}));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (!isIdempotencyConflict(error)) throw error;
|
|
430
|
+
}
|
|
431
|
+
for (const artifact of flakeArtifacts) {
|
|
432
|
+
if (Number(artifact.sizeBytes || 0) > MAX_ARTIFACT_BYTES) {
|
|
433
|
+
console.warn(`[flake-ingest] skipping oversized artifact ${artifact.name} (${artifact.sizeBytes} bytes)`);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const suite = artifact.name.slice(14);
|
|
437
|
+
const download = await depotRpc("GetArtifactDownloadURL", { artifactId: artifact.artifactId });
|
|
438
|
+
if (!download.url) {
|
|
439
|
+
console.warn(`[flake-ingest] no download url for artifact ${artifact.name}`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const zipResponse = await fetch(download.url);
|
|
443
|
+
if (!zipResponse.ok) throw new Error(`artifact download failed: HTTP ${zipResponse.status}`);
|
|
444
|
+
const files = await unzip(new Uint8Array(await zipResponse.arrayBuffer()));
|
|
445
|
+
const malformedRecords = [];
|
|
446
|
+
const records = Object.entries(files).filter(([name]) => name.endsWith(".jsonl")).flatMap(([name, bytes]) => new TextDecoder().decode(bytes).split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
|
|
447
|
+
let json;
|
|
448
|
+
try {
|
|
449
|
+
json = JSON.parse(line);
|
|
450
|
+
} catch {
|
|
451
|
+
malformedRecords.push(`Malformed record in ${name}`);
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
const parsed = FlakeRecord.safeParse(json);
|
|
455
|
+
if (!parsed.success) {
|
|
456
|
+
malformedRecords.push(`Malformed record in ${name}`);
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
return [parsed.data];
|
|
460
|
+
}));
|
|
461
|
+
const summaryBytes = Object.entries(files).find(([name]) => name === "suite-summary.json" || name.endsWith("/suite-summary.json"))?.[1];
|
|
462
|
+
let summary;
|
|
463
|
+
if (summaryBytes) try {
|
|
464
|
+
summary = FlakeSuiteSummary.parse(JSON.parse(new TextDecoder().decode(summaryBytes)));
|
|
465
|
+
} catch {
|
|
466
|
+
console.error(`[flake-ingest] invalid suite-summary.json in ${artifact.name}; skipping artifact`);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (summary && summary.unknownFlakeCount !== records.filter((record) => record.kind === "unknown").length) malformedRecords.push("Unknown flake records do not match the full runner result");
|
|
470
|
+
if (summary?.tests && summary.tests.length !== summary.testCount) malformedRecords.push("Per-test results do not match the full runner test count");
|
|
471
|
+
if (summary && malformedRecords.length > 0) {
|
|
472
|
+
summary.status = "incomplete";
|
|
473
|
+
summary.diagnostics.push(...new Set(malformedRecords));
|
|
474
|
+
}
|
|
475
|
+
if (records.length === 0 && !summary) continue;
|
|
476
|
+
const runId = `${run.runId}-${artifact.attempt || 1}`;
|
|
477
|
+
try {
|
|
478
|
+
await stream.append({
|
|
479
|
+
type: "events.iterate.com/flakes/run-recorded",
|
|
480
|
+
idempotencyKey: `flakes/run:${runId}:${suite}`,
|
|
481
|
+
payload: {
|
|
482
|
+
runId,
|
|
483
|
+
suite,
|
|
484
|
+
branch: summary ? summary.branch : checkRun.check_suite?.head_branch || "unknown",
|
|
485
|
+
commit: summary ? summary.headSha : checkRun.head_sha,
|
|
486
|
+
records,
|
|
487
|
+
summary
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (!isIdempotencyConflict(error)) throw error;
|
|
492
|
+
}
|
|
493
|
+
console.log(`[flake-ingest] ingested ${records.length} records from ${artifact.name} (run ${runId})`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} catch (_) {
|
|
497
|
+
_usingCtx3.e = _;
|
|
498
|
+
} finally {
|
|
499
|
+
_usingCtx3.d();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Internal RPC surface used by smoke checks and debugging. */
|
|
503
|
+
async getState() {
|
|
504
|
+
await (await this.registry()).catchUp("flake-dashboard");
|
|
505
|
+
return (await this.snapshot()).state;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
/**
|
|
509
|
+
* One instance runs on the project's `/flakes` stream. CI appends
|
|
510
|
+
* `run-recorded` events; this processor folds them into per-test stats, owns
|
|
511
|
+
* the GitHub issue render as its durable obligation, and proposes lifecycle
|
|
512
|
+
* transitions when a default-branch streak crosses a threshold.
|
|
513
|
+
*/
|
|
514
|
+
var FlakeDashboardProcessor = class extends StreamProcessor {
|
|
515
|
+
contract = FlakeDashboardProcessorContract;
|
|
516
|
+
/**
|
|
517
|
+
* Runtime-only render attempt guard. The render-settled stream event is the
|
|
518
|
+
* durable truth: after an eviction this is false and the next caught-up
|
|
519
|
+
* pass re-drives any still-unrendered data.
|
|
520
|
+
*/
|
|
521
|
+
#liveRender = false;
|
|
522
|
+
reduce({ event, state }) {
|
|
523
|
+
switch (event.type) {
|
|
524
|
+
case flakeEventTypes.created:
|
|
525
|
+
if (state.birthCertificate !== null) return state;
|
|
526
|
+
return {
|
|
527
|
+
...state,
|
|
528
|
+
birthCertificate: event.payload,
|
|
529
|
+
lastDataOffset: event.offset
|
|
530
|
+
};
|
|
531
|
+
case flakeEventTypes.runRecorded: {
|
|
532
|
+
const config = state.birthCertificate?.config;
|
|
533
|
+
if (config === void 0) return state;
|
|
534
|
+
const onDefaultBranch = event.payload.branch === config.defaultBranch;
|
|
535
|
+
const { summary, suite, runId, commit, records } = event.payload;
|
|
536
|
+
const tests = { ...state.tests };
|
|
537
|
+
for (const record of event.payload.records) {
|
|
538
|
+
const existing = tests[record.name];
|
|
539
|
+
const runAt = summary?.startedAt || record.at;
|
|
540
|
+
const wrapperAt = existing?.lastMainWrapperAt[suite];
|
|
541
|
+
const unknownAt = state.unknownFlakes[suite]?.[record.name]?.lastRunAt;
|
|
542
|
+
const staleKind = existing && onDefaultBranch && (record.kind === "unknown" ? wrapperAt && runAt < wrapperAt : unknownAt && runAt < unknownAt);
|
|
543
|
+
const kind = staleKind ? existing.kind : record.kind;
|
|
544
|
+
const counts = { ...existing?.counts };
|
|
545
|
+
counts[record.outcome] = (counts[record.outcome] || 0) + 1;
|
|
546
|
+
const streak = existing?.defaultBranchStreak || null;
|
|
547
|
+
const nextStreak = !onDefaultBranch || staleKind ? streak : record.outcome === "unexpected-error" ? null : streak && streak.outcome === record.outcome ? {
|
|
548
|
+
...streak,
|
|
549
|
+
runs: streak.runs + 1,
|
|
550
|
+
lastAt: record.at
|
|
551
|
+
} : {
|
|
552
|
+
outcome: record.outcome,
|
|
553
|
+
runs: 1,
|
|
554
|
+
firstAt: record.at,
|
|
555
|
+
lastAt: record.at
|
|
556
|
+
};
|
|
557
|
+
const flakeStruck = record.outcome === "flake-fail" || record.outcome === "retried-pass";
|
|
558
|
+
const errorSample = record.error !== void 0 && (record.outcome === "retried-pass" || record.outcome === "unexpected-error") ? {
|
|
559
|
+
error: record.error,
|
|
560
|
+
commit: event.payload.commit,
|
|
561
|
+
at: record.at
|
|
562
|
+
} : null;
|
|
563
|
+
tests[record.name] = {
|
|
564
|
+
kind,
|
|
565
|
+
pattern: staleKind ? existing.pattern : record.pattern || existing?.pattern || "",
|
|
566
|
+
suites: existing?.suites.includes(event.payload.suite) ? existing.suites : [...existing?.suites || [], event.payload.suite],
|
|
567
|
+
lastSeenOffset: {
|
|
568
|
+
...existing?.lastSeenOffset,
|
|
569
|
+
[event.payload.suite]: event.offset
|
|
570
|
+
},
|
|
571
|
+
recent: [...existing?.recent || [], {
|
|
572
|
+
outcome: record.outcome,
|
|
573
|
+
commit: event.payload.commit,
|
|
574
|
+
at: record.at
|
|
575
|
+
}].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).slice(-10),
|
|
576
|
+
counts,
|
|
577
|
+
recentErrors: errorSample ? [...existing?.recentErrors || [], errorSample].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).slice(-3) : existing?.recentErrors || [],
|
|
578
|
+
lastFlakeAt: flakeStruck && (!existing?.lastFlakeAt || Date.parse(record.at) > Date.parse(existing.lastFlakeAt)) ? record.at : existing?.lastFlakeAt || null,
|
|
579
|
+
firstRecordedAt: existing && existing.kind === kind ? existing.firstRecordedAt : record.at,
|
|
580
|
+
lastRecordedAt: existing && Date.parse(existing.lastRecordedAt) > Date.parse(record.at) ? existing.lastRecordedAt : record.at,
|
|
581
|
+
defaultBranchStreak: nextStreak,
|
|
582
|
+
lastMainWrapperAt: onDefaultBranch && record.kind !== "unknown" ? {
|
|
583
|
+
...existing?.lastMainWrapperAt,
|
|
584
|
+
[suite]: wrapperAt && wrapperAt > runAt ? wrapperAt : runAt
|
|
585
|
+
} : existing?.lastMainWrapperAt || {},
|
|
586
|
+
proposed: existing?.proposed || []
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const suites = {
|
|
590
|
+
...state.suites,
|
|
591
|
+
[event.payload.suite]: { recentRunOffsets: [...state.suites[event.payload.suite]?.recentRunOffsets || [], ...(event.payload.summary ? event.payload.summary.status === "complete" : event.payload.records.length > 0) ? [event.offset] : []].slice(-3) }
|
|
592
|
+
};
|
|
593
|
+
const mainRuns = { ...state.mainRuns };
|
|
594
|
+
const previousInventory = mainRuns[suite]?.inventory || null;
|
|
595
|
+
const inventory = onDefaultBranch && summary?.status === "complete" && summary.tests && (!previousInventory || summary.startedAt >= previousInventory.startedAt) ? {
|
|
596
|
+
startedAt: summary.startedAt,
|
|
597
|
+
names: [...new Set(summary.tests.map((test) => test.name))]
|
|
598
|
+
} : previousInventory;
|
|
599
|
+
const unknownFlakes = { ...state.unknownFlakes };
|
|
600
|
+
if (onDefaultBranch) {
|
|
601
|
+
const unknowns = { ...unknownFlakes[suite] };
|
|
602
|
+
const recordedNames = new Set(records.map((record) => record.name));
|
|
603
|
+
const results = /* @__PURE__ */ new Map();
|
|
604
|
+
for (const test of summary?.tests || []) {
|
|
605
|
+
const previous = results.get(test.name);
|
|
606
|
+
results.set(test.name, previous === "fail" || test.outcome === "fail" ? "fail" : previous === "skip" || test.outcome === "skip" ? "skip" : "pass");
|
|
607
|
+
}
|
|
608
|
+
if (summary) for (const [name, test] of Object.entries(unknowns)) {
|
|
609
|
+
if (recordedNames.has(name)) continue;
|
|
610
|
+
const wrapperAt = tests[name]?.lastMainWrapperAt[suite];
|
|
611
|
+
if (wrapperAt && summary.startedAt < wrapperAt) continue;
|
|
612
|
+
const outcome = results.get(name);
|
|
613
|
+
if (!outcome) continue;
|
|
614
|
+
const pass = outcome === "pass" && summary.status === "complete";
|
|
615
|
+
const fail = outcome === "fail";
|
|
616
|
+
if (!fail && summary.startedAt <= test.lastRunAt) continue;
|
|
617
|
+
const passStreak = fail ? 0 : test.passStreak + Number(pass);
|
|
618
|
+
unknowns[name] = {
|
|
619
|
+
...test,
|
|
620
|
+
passStreak,
|
|
621
|
+
lastRunAt: summary.startedAt > test.lastRunAt ? summary.startedAt : test.lastRunAt,
|
|
622
|
+
recent: pass || fail ? test.recent.concat({
|
|
623
|
+
outcome: pass ? "pass" : "unexpected-error",
|
|
624
|
+
commit,
|
|
625
|
+
at: summary.startedAt
|
|
626
|
+
}).sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).slice(-10) : test.recent
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
for (const record of records) {
|
|
630
|
+
const previous = unknowns[record.name];
|
|
631
|
+
const lastRunAt = summary?.startedAt || record.at;
|
|
632
|
+
const wrapperAt = tests[record.name]?.lastMainWrapperAt[suite];
|
|
633
|
+
if (record.kind === "unknown" && wrapperAt && lastRunAt < wrapperAt) continue;
|
|
634
|
+
if (record.kind !== "unknown") {
|
|
635
|
+
if (previous && lastRunAt <= previous.lastRunAt) continue;
|
|
636
|
+
delete unknowns[record.name];
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
unknowns[record.name] = {
|
|
640
|
+
record: previous && Date.parse(previous.record.at) > Date.parse(record.at) ? previous.record : record,
|
|
641
|
+
passStreak: 0,
|
|
642
|
+
lastRunAt: previous && previous.lastRunAt > lastRunAt ? previous.lastRunAt : lastRunAt,
|
|
643
|
+
recent: [...previous?.recent || [], {
|
|
644
|
+
outcome: record.outcome,
|
|
645
|
+
commit,
|
|
646
|
+
at: record.at
|
|
647
|
+
}].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).slice(-10)
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (inventory) {
|
|
651
|
+
const names = new Set(inventory.names);
|
|
652
|
+
for (const [name, test] of Object.entries(unknowns)) if (test.lastRunAt <= inventory.startedAt && !names.has(name)) delete unknowns[name];
|
|
653
|
+
}
|
|
654
|
+
unknownFlakes[suite] = unknowns;
|
|
655
|
+
}
|
|
656
|
+
if (onDefaultBranch && summary) {
|
|
657
|
+
const previous = mainRuns[suite];
|
|
658
|
+
const incoming = {
|
|
659
|
+
runId,
|
|
660
|
+
commit,
|
|
661
|
+
summary: {
|
|
662
|
+
...summary,
|
|
663
|
+
tests: void 0
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
mainRuns[suite] = {
|
|
667
|
+
inventory,
|
|
668
|
+
latest: !previous || summary.startedAt >= previous.latest.summary.startedAt ? incoming : previous.latest,
|
|
669
|
+
complete: summary.status === "complete" && (!previous?.complete || summary.startedAt >= previous.complete.summary.startedAt) ? incoming : previous?.complete || null
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
...state,
|
|
674
|
+
tests,
|
|
675
|
+
suites,
|
|
676
|
+
mainRuns,
|
|
677
|
+
unknownFlakes,
|
|
678
|
+
lastDataOffset: event.offset
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
case flakeEventTypes.transitionProposed: {
|
|
682
|
+
const test = state.tests[event.payload.testName];
|
|
683
|
+
if (test === void 0) return {
|
|
684
|
+
...state,
|
|
685
|
+
lastDataOffset: event.offset
|
|
686
|
+
};
|
|
687
|
+
const marker = `${event.payload.transition}:${event.payload.evidence.firstAt}`;
|
|
688
|
+
return {
|
|
689
|
+
...state,
|
|
690
|
+
tests: {
|
|
691
|
+
...state.tests,
|
|
692
|
+
[event.payload.testName]: {
|
|
693
|
+
...test,
|
|
694
|
+
proposed: test.proposed.includes(marker) ? test.proposed : [...test.proposed, marker]
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
lastDataOffset: event.offset
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
case flakeEventTypes.dashboardRenderSettled:
|
|
701
|
+
if (event.payload.result.status !== "succeeded") return state;
|
|
702
|
+
return {
|
|
703
|
+
...state,
|
|
704
|
+
render: {
|
|
705
|
+
throughOffset: event.payload.throughOffset,
|
|
706
|
+
issueNumber: event.payload.result.issueNumber,
|
|
707
|
+
issueUrl: event.payload.result.issueUrl
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
processEvent(args) {
|
|
713
|
+
const { append, blockProcessorWhile, delivery, event, runInBackground, state } = args;
|
|
714
|
+
if (event?.type === flakeEventTypes.runRecorded) for (const [testName, test] of Object.entries(state.tests)) {
|
|
715
|
+
if (isSentinel(testName)) continue;
|
|
716
|
+
const streak = test.defaultBranchStreak;
|
|
717
|
+
if (streak === null) continue;
|
|
718
|
+
const transition = streak.outcome === "pass" ? "unwrap" : streak.outcome === "flake-fail" ? "switch-to-failing" : streak.outcome === "unexpected-pass" ? "unwrap-failing" : null;
|
|
719
|
+
if (transition === null) continue;
|
|
720
|
+
const threshold = flakeTransitionThresholds[transition];
|
|
721
|
+
const spanMs = Date.parse(streak.lastAt) - Date.parse(streak.firstAt);
|
|
722
|
+
if (streak.runs < threshold.runs || spanMs < threshold.minSpanMs) continue;
|
|
723
|
+
const marker = `${transition}:${streak.firstAt}`;
|
|
724
|
+
if (test.proposed.includes(marker)) continue;
|
|
725
|
+
blockProcessorWhile(() => this.#appendTolerantOfSettledRace(append, {
|
|
726
|
+
type: flakeEventTypes.transitionProposed,
|
|
727
|
+
idempotencyKey: this.idempotencyKey(`transition:${testName}:${marker}`),
|
|
728
|
+
payload: {
|
|
729
|
+
testName,
|
|
730
|
+
transition,
|
|
731
|
+
evidence: {
|
|
732
|
+
consecutiveRuns: streak.runs,
|
|
733
|
+
firstAt: streak.firstAt,
|
|
734
|
+
lastAt: streak.lastAt
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
if (!delivery.caughtUp) return;
|
|
740
|
+
if (state.birthCertificate === null) return;
|
|
741
|
+
if (state.lastDataOffset <= (state.render?.throughOffset || 0)) return;
|
|
742
|
+
if (this.#liveRender) return;
|
|
743
|
+
const throughOffset = state.lastDataOffset;
|
|
744
|
+
this.#liveRender = true;
|
|
745
|
+
runInBackground(async () => {
|
|
746
|
+
let result;
|
|
747
|
+
try {
|
|
748
|
+
result = await this.deps.renderDashboard(state);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
result = {
|
|
751
|
+
status: "failed",
|
|
752
|
+
error: (error instanceof Error ? error.message : String(error)).slice(0, 8e3) || "Unknown render failure."
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
await this.#appendTolerantOfSettledRace(append, {
|
|
757
|
+
type: flakeEventTypes.dashboardRenderSettled,
|
|
758
|
+
idempotencyKey: this.idempotencyKey(`dashboard-render:${throughOffset}:${result.status}`),
|
|
759
|
+
payload: {
|
|
760
|
+
throughOffset,
|
|
761
|
+
result
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
} finally {
|
|
765
|
+
this.#liveRender = false;
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Growing streaks and overlapping render attempts can race on one
|
|
771
|
+
* processor-owned key with fresher bodies. The first committed fact wins;
|
|
772
|
+
* a same-key/different-body rejection means the work is already settled.
|
|
773
|
+
*/
|
|
774
|
+
async #appendTolerantOfSettledRace(append, event) {
|
|
775
|
+
try {
|
|
776
|
+
await append(event);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
if (!isIdempotencyConflict(error)) throw error;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
const DASHBOARD_MARKER = "<!-- iterate-flake-dashboard -->";
|
|
783
|
+
/**
|
|
784
|
+
* Upsert the GitHub "Flake dashboard" issue from folded state. Mechanical by
|
|
785
|
+
* design (the AI-linter publication stance): all decisions were made when the
|
|
786
|
+
* events reduced. The processor is the single writer, so a plain body
|
|
787
|
+
* overwrite is race-free; the marker plus remembered issue number make the
|
|
788
|
+
* upsert idempotent across retried renders.
|
|
789
|
+
*/
|
|
790
|
+
async function renderFlakeDashboardIssue(itx, state) {
|
|
791
|
+
const config = state.birthCertificate?.config;
|
|
792
|
+
if (config === void 0) throw new Error("Flake dashboard has no birth certificate.");
|
|
793
|
+
const repos = await itx.repos.list();
|
|
794
|
+
const link = (await Promise.all(repos.map(async ({ path }) => (await itx.repos.get(path).processor.snapshot()).state.github))).find((candidate) => candidate !== null && candidate.owner === config.repository.owner && candidate.repo === config.repository.repo);
|
|
795
|
+
if (link === null || link === void 0) throw new Error(`No linked GitHub connection for ${config.repository.owner}/${config.repository.repo}; link a repo to that repository before the dashboard can render.`);
|
|
796
|
+
const octokit = itx.integrations.github.get(link.connection).octokit;
|
|
797
|
+
const params = {
|
|
798
|
+
owner: config.repository.owner,
|
|
799
|
+
repo: config.repository.repo
|
|
800
|
+
};
|
|
801
|
+
const body = renderBody(state);
|
|
802
|
+
const rememberedIssueNumber = state.render?.issueNumber || null;
|
|
803
|
+
if (rememberedIssueNumber !== null) return {
|
|
804
|
+
status: "succeeded",
|
|
805
|
+
issueNumber: rememberedIssueNumber,
|
|
806
|
+
issueUrl: (await octokit.rest.issues.update({
|
|
807
|
+
...params,
|
|
808
|
+
issue_number: rememberedIssueNumber,
|
|
809
|
+
body,
|
|
810
|
+
title: config.issueTitle
|
|
811
|
+
})).data.html_url
|
|
812
|
+
};
|
|
813
|
+
const existing = (await octokit.paginate("GET /repos/{owner}/{repo}/issues", {
|
|
814
|
+
...params,
|
|
815
|
+
state: "open",
|
|
816
|
+
per_page: 100
|
|
817
|
+
})).find((issue) => issue.body?.includes(DASHBOARD_MARKER) === true);
|
|
818
|
+
if (existing !== void 0) {
|
|
819
|
+
await octokit.rest.issues.update({
|
|
820
|
+
...params,
|
|
821
|
+
issue_number: existing.number,
|
|
822
|
+
body,
|
|
823
|
+
title: config.issueTitle
|
|
824
|
+
});
|
|
825
|
+
return {
|
|
826
|
+
status: "succeeded",
|
|
827
|
+
issueNumber: existing.number,
|
|
828
|
+
issueUrl: existing.html_url
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
const created = await octokit.rest.issues.create({
|
|
832
|
+
...params,
|
|
833
|
+
title: config.issueTitle,
|
|
834
|
+
body
|
|
835
|
+
});
|
|
836
|
+
return {
|
|
837
|
+
status: "succeeded",
|
|
838
|
+
issueNumber: created.data.number,
|
|
839
|
+
issueUrl: created.data.html_url
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
const OUTCOME_EMOJI = {
|
|
843
|
+
pass: "🟩",
|
|
844
|
+
"unexpected-pass": "🟩",
|
|
845
|
+
"flake-fail": "🟥",
|
|
846
|
+
"pinned-fail": "🟥",
|
|
847
|
+
"retried-pass": "🟥",
|
|
848
|
+
"unexpected-error": "❌"
|
|
849
|
+
};
|
|
850
|
+
/**
|
|
851
|
+
* The deliberate canary flakes are identified by naming convention — every
|
|
852
|
+
* suite's sentinel test contains this phrase ("flake sentinel",
|
|
853
|
+
* "flake sentinel (specs)", …). A convention beats a record flag: it needs no
|
|
854
|
+
* wire-format field and holds for all historical records.
|
|
855
|
+
*/
|
|
856
|
+
function isSentinel(testName) {
|
|
857
|
+
return testName.includes("flake sentinel");
|
|
858
|
+
}
|
|
859
|
+
const MONTHS = [
|
|
860
|
+
"Jan",
|
|
861
|
+
"Feb",
|
|
862
|
+
"Mar",
|
|
863
|
+
"Apr",
|
|
864
|
+
"May",
|
|
865
|
+
"Jun",
|
|
866
|
+
"Jul",
|
|
867
|
+
"Aug",
|
|
868
|
+
"Sep",
|
|
869
|
+
"Oct",
|
|
870
|
+
"Nov",
|
|
871
|
+
"Dec"
|
|
872
|
+
];
|
|
873
|
+
/** "Sep 4, 7:16am" (UTC) — ISO timestamps read like log spam in the table. */
|
|
874
|
+
function shortDate(iso) {
|
|
875
|
+
const date = new Date(iso);
|
|
876
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
877
|
+
const hours = date.getUTCHours();
|
|
878
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
|
879
|
+
return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${hours % 12 || 12}:${minutes}${hours >= 12 ? "pm" : "am"}`;
|
|
880
|
+
}
|
|
881
|
+
/** Exported for tests: the table is a pure projection of folded state. */
|
|
882
|
+
function renderBody(state) {
|
|
883
|
+
const tests = Object.entries(state.tests).sort(([a], [b]) => a.localeCompare(b));
|
|
884
|
+
const lastRecordedAt = [...tests.map(([, test]) => test.lastRecordedAt), ...Object.values(state.mainRuns).map((result) => result.latest.summary.finishedAt)].sort().at(-1);
|
|
885
|
+
const tracked = tests.filter(([, test]) => test.kind !== "unknown");
|
|
886
|
+
const visible = tracked.filter(([, test]) => Object.entries(test.lastSeenOffset).some(([suite, seen]) => {
|
|
887
|
+
const windowStart = state.suites[suite]?.recentRunOffsets[0];
|
|
888
|
+
return windowStart === void 0 || seen >= windowStart;
|
|
889
|
+
}));
|
|
890
|
+
const retiredCount = tracked.length - visible.length;
|
|
891
|
+
const config = state.birthCertificate?.config;
|
|
892
|
+
const count = (test, outcome) => test.counts[outcome] || 0;
|
|
893
|
+
const row = ([name, test]) => {
|
|
894
|
+
const runs = Object.values(test.counts).reduce((total, n) => total + n, 0);
|
|
895
|
+
const info = [
|
|
896
|
+
`pattern: \`${`/${test.pattern}/`.replaceAll(/\s+/gu, " ").replaceAll("`", "'").replaceAll("|", "\\|")}\``,
|
|
897
|
+
`suites: ${test.suites.join(", ")}`,
|
|
898
|
+
...test.proposed.length === 0 ? [] : [`proposed: ${test.proposed.map((p) => p.split(":")[0]).join(", ")}`]
|
|
899
|
+
].join("<br>");
|
|
900
|
+
const gated = count(test, "pass") + count(test, "flake-fail");
|
|
901
|
+
const stats = (test.kind === "failing" ? [
|
|
902
|
+
`runs: ${runs}`,
|
|
903
|
+
`pin held: ${count(test, "pinned-fail")}`,
|
|
904
|
+
`unexpected passes: ${count(test, "unexpected-pass")}`,
|
|
905
|
+
`pinned since: ${shortDate(test.firstRecordedAt)}`
|
|
906
|
+
] : [
|
|
907
|
+
`runs: ${runs}`,
|
|
908
|
+
`flake rate: ${gated === 0 ? "—" : `${Math.round(count(test, "flake-fail") / gated * 100)}%`}`,
|
|
909
|
+
`last flake: ${test.lastFlakeAt ? shortDate(test.lastFlakeAt) : "never"}`
|
|
910
|
+
]).join("<br>");
|
|
911
|
+
const streak = [test.recent.map((entry) => {
|
|
912
|
+
const emoji = OUTCOME_EMOJI[entry.outcome];
|
|
913
|
+
if (config === void 0) return emoji;
|
|
914
|
+
const { owner, repo } = config.repository;
|
|
915
|
+
return `[${emoji}](https://github.com/${owner}/${repo}/commit/${entry.commit})`;
|
|
916
|
+
}).join("") || "—", ...test.defaultBranchStreak === null || config === void 0 ? [] : [`${test.defaultBranchStreak.runs}× ${test.defaultBranchStreak.outcome} (${config.defaultBranch})`]].join("<br>");
|
|
917
|
+
return [
|
|
918
|
+
`\`${name}\``,
|
|
919
|
+
info,
|
|
920
|
+
stats,
|
|
921
|
+
streak
|
|
922
|
+
].join(" | ");
|
|
923
|
+
};
|
|
924
|
+
const sections = [
|
|
925
|
+
{
|
|
926
|
+
title: "Flakes",
|
|
927
|
+
legend: `_createFlake-wrapped: 🟩 passed · 🟥 the known flake struck · ❌ failed differently than expected. Suggest removing the wrapper after ${flakeTransitionThresholds.unwrap.runs} consecutive main passes, with no minimum elapsed time._`,
|
|
928
|
+
tests: visible.filter(([name, test]) => test.kind === "flake" && !isSentinel(name))
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
title: "Failures",
|
|
932
|
+
legend: "_createFailing pins: 🟥 the pinned bug is present · 🟩 passed unexpectedly — the bug may be fixed · ❌ failed differently than expected._",
|
|
933
|
+
tests: visible.filter(([, test]) => test.kind === "failing")
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
title: "Sentinels",
|
|
937
|
+
legend: "_Deliberate canary flakes proving the recording pipeline works: 🟩 passed · 🟥 the known flake struck · ❌ failed differently than expected._",
|
|
938
|
+
tests: visible.filter(([name, test]) => test.kind === "flake" && isSentinel(name))
|
|
939
|
+
}
|
|
940
|
+
].filter((section) => section.tests.length > 0);
|
|
941
|
+
return [
|
|
942
|
+
DASHBOARD_MARKER,
|
|
943
|
+
"Test health, folded from CI-reported runs: [`createFlake`](https://github.com/iterate/iterate/blob/main/packages/shared/src/test-support/flake-test.ts) wraps, [`createFailing`](https://github.com/iterate/iterate/blob/main/packages/shared/src/test-support/failing-test.ts) pins, and unclassified flaky tests caught by CI retries. Maintained automatically — edits to this body will be overwritten. Squares show the last 10 outcomes, oldest→newest, and link to their commits. Wrapped tests show all branches; unknown flakes show main only. Lifecycle streak counts use main only.",
|
|
944
|
+
...sections.filter((section) => section.title !== "Sentinels").flatMap(renderSection),
|
|
945
|
+
...renderUnknownFlakes(state),
|
|
946
|
+
...sections.filter((section) => section.title === "Sentinels").flatMap(renderSection),
|
|
947
|
+
"",
|
|
948
|
+
`_Last recorded outcome: ${lastRecordedAt || "none"}._`,
|
|
949
|
+
...retiredCount === 0 ? [] : [`_${retiredCount} retired ${retiredCount === 1 ? "test" : "tests"} hidden (absent from the last 3 runs of their suite)._`]
|
|
950
|
+
].join("\n");
|
|
951
|
+
function renderSection(section) {
|
|
952
|
+
const collapsed = section.title === "Failures" || section.title === "Sentinels";
|
|
953
|
+
const suiteCounts = /* @__PURE__ */ new Map();
|
|
954
|
+
for (const [, test] of section.tests) for (const suite of test.suites) suiteCounts.set(suite, (suiteCounts.get(suite) || 0) + 1);
|
|
955
|
+
const summary = [`${section.tests.length} ${section.tests.length === 1 ? "test" : "tests"}`, ...[...suiteCounts].sort(([a], [b]) => a.localeCompare(b)).map(([suite, count]) => `${suite}: ${count}`)].join(" · ");
|
|
956
|
+
return [
|
|
957
|
+
"",
|
|
958
|
+
`## ${section.title}`,
|
|
959
|
+
"",
|
|
960
|
+
...collapsed ? [
|
|
961
|
+
"<details>",
|
|
962
|
+
`<summary>${summary}</summary>`,
|
|
963
|
+
""
|
|
964
|
+
] : [],
|
|
965
|
+
section.legend,
|
|
966
|
+
"",
|
|
967
|
+
"test | info | stats | streak",
|
|
968
|
+
"--- | --- | --- | ---",
|
|
969
|
+
...section.tests.map(row),
|
|
970
|
+
...collapsed ? ["", "</details>"] : []
|
|
971
|
+
];
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
function renderUnknownFlakes(state) {
|
|
975
|
+
const suites = [...new Set([...Object.keys(state.suites), ...Object.keys(state.mainRuns)])].sort();
|
|
976
|
+
const escape = (text) => text.replaceAll(/\s+/gu, " ").replaceAll(/([\\`*_[\]|<>])/gu, "\\$1");
|
|
977
|
+
const rows = suites.flatMap((suite) => Object.entries(state.unknownFlakes[suite] || {}).filter(([, test]) => test.passStreak < flakeTransitionThresholds.unwrap.runs).sort(([a], [b]) => a.localeCompare(b)).map(([, test]) => {
|
|
978
|
+
const record = test.record;
|
|
979
|
+
const config = state.birthCertificate.config;
|
|
980
|
+
const squares = test.recent.map(({ outcome, commit }) => `[${OUTCOME_EMOJI[outcome]}](https://github.com/${config.repository.owner}/${config.repository.repo}/commit/${commit})`).join("");
|
|
981
|
+
return [
|
|
982
|
+
escape(record.name),
|
|
983
|
+
`\`${(record.error || "No error sample recorded").replaceAll(/\s+/gu, " ").replaceAll("`", "'").replaceAll("|", "\\|").slice(0, 300)}\``,
|
|
984
|
+
escape(suite),
|
|
985
|
+
`${squares}<br>${test.passStreak}/${flakeTransitionThresholds.unwrap.runs} consecutive passes`
|
|
986
|
+
].join(" | ");
|
|
987
|
+
}));
|
|
988
|
+
const completeCount = suites.filter((suite) => state.mainRuns[suite]?.complete).length;
|
|
989
|
+
return [
|
|
990
|
+
"",
|
|
991
|
+
"## Unknown flakes",
|
|
992
|
+
"",
|
|
993
|
+
`_Any main retry adds a test here. Remove it after ${flakeTransitionThresholds.unwrap.runs} consecutive main passes, wrapper adoption, or absence from a complete main run's full test list. Failures reset the streak. Skips and incomplete results cannot advance it or prove deletion. 🟩 passed · 🟥 needed a retry · ❌ failed. PR results do not affect this table._`,
|
|
994
|
+
"",
|
|
995
|
+
...suites.map((suite) => {
|
|
996
|
+
const result = state.mainRuns[suite];
|
|
997
|
+
if (!result) return `- **${escape(suite)}:** awaiting a complete main result.`;
|
|
998
|
+
const complete = result.complete;
|
|
999
|
+
const incomplete = result.latest.summary.status === "incomplete" ? ` Latest attempt [${result.latest.commit.slice(0, 7)}](${result.latest.summary.runUrl}) incomplete: ${escape(result.latest.summary.diagnostics.join("; "))}.` : "";
|
|
1000
|
+
if (!complete) return `- **${escape(suite)}:** awaiting a complete main result.${incomplete}`;
|
|
1001
|
+
return `- **${escape(suite)}:** [${complete.commit.slice(0, 7)}](${complete.summary.runUrl}) · ${shortDate(complete.summary.finishedAt)} UTC · ${complete.summary.testCount} tests · ${complete.summary.failedCount} failed.${incomplete}`;
|
|
1002
|
+
}),
|
|
1003
|
+
"",
|
|
1004
|
+
...rows.length > 0 ? [
|
|
1005
|
+
"test | first failure | suite | streak (main)",
|
|
1006
|
+
"--- | --- | --- | ---",
|
|
1007
|
+
...rows
|
|
1008
|
+
] : [completeCount === 0 ? "_Awaiting the first complete main results._" : "_No active unknown flakes. Passing streaks are evidence of stability, not proof that the root cause is fixed._"]
|
|
1009
|
+
];
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Minimal zip reader on the runtime's own DecompressionStream — deliberately
|
|
1013
|
+
* not a dependency. The format surface is narrow by construction: one
|
|
1014
|
+
* producer (GitHub's artifact service), a 5MB size cap upstream, and reading
|
|
1015
|
+
* via the central directory (sizes come from there, so streaming-writer data
|
|
1016
|
+
* descriptors don't matter). No zip64 — impossible under the size cap — and
|
|
1017
|
+
* anything unexpected throws, which ingestion treats as a logged drop.
|
|
1018
|
+
*/
|
|
1019
|
+
async function unzip(bytes) {
|
|
1020
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1021
|
+
let eocd = -1;
|
|
1022
|
+
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 65535); i--) if (view.getUint32(i, true) === 101010256) {
|
|
1023
|
+
eocd = i;
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
if (eocd < 0) throw new Error("not a zip: no end-of-central-directory record");
|
|
1027
|
+
const entryCount = view.getUint16(eocd + 10, true);
|
|
1028
|
+
const files = {};
|
|
1029
|
+
let offset = view.getUint32(eocd + 16, true);
|
|
1030
|
+
for (let i = 0; i < entryCount; i++) {
|
|
1031
|
+
if (view.getUint32(offset, true) !== 33639248) throw new Error("corrupt zip: bad central directory entry signature");
|
|
1032
|
+
const method = view.getUint16(offset + 10, true);
|
|
1033
|
+
const compressedSize = view.getUint32(offset + 20, true);
|
|
1034
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
1035
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
1036
|
+
const commentLength = view.getUint16(offset + 32, true);
|
|
1037
|
+
const localHeaderOffset = view.getUint32(offset + 42, true);
|
|
1038
|
+
const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength));
|
|
1039
|
+
const localNameLength = view.getUint16(localHeaderOffset + 26, true);
|
|
1040
|
+
const localExtraLength = view.getUint16(localHeaderOffset + 28, true);
|
|
1041
|
+
const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
|
|
1042
|
+
const data = bytes.slice(dataStart, dataStart + compressedSize);
|
|
1043
|
+
if (method === 0) files[name] = data;
|
|
1044
|
+
else if (method === 8) {
|
|
1045
|
+
const inflated = new Response(data).body.pipeThrough(new DecompressionStream("deflate-raw"));
|
|
1046
|
+
files[name] = new Uint8Array(await new Response(inflated).arrayBuffer());
|
|
1047
|
+
} else throw new Error(`unsupported zip compression method ${method} for ${name}`);
|
|
1048
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
1049
|
+
}
|
|
1050
|
+
return files;
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
1053
|
+
export { FlakeDashboardApp };
|
|
1054
|
+
|
|
1055
|
+
//# sourceMappingURL=configured-worker.mjs.map
|