@intentic/sandbox-contract 1.170.0 → 1.172.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/dist/agent-catalog.d.ts +1 -0
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +1 -0
- package/dist/agent-catalog.js.map +1 -1
- package/dist/chores/chores.d.ts +45 -0
- package/dist/chores/chores.d.ts.map +1 -0
- package/dist/chores/chores.js +487 -0
- package/dist/chores/chores.js.map +1 -0
- package/dist/chores/digest.d.ts +3 -0
- package/dist/chores/digest.d.ts.map +1 -0
- package/dist/chores/digest.js +0 -0
- package/dist/chores/digest.js.map +1 -0
- package/dist/chores/index.d.ts +10 -0
- package/dist/chores/index.d.ts.map +1 -0
- package/dist/chores/index.js +6 -0
- package/dist/chores/index.js.map +1 -0
- package/dist/chores/probes.d.ts +15 -0
- package/dist/chores/probes.d.ts.map +1 -0
- package/dist/chores/probes.js +177 -0
- package/dist/chores/probes.js.map +1 -0
- package/dist/chores/prompt.d.ts +14 -0
- package/dist/chores/prompt.d.ts.map +1 -0
- package/dist/chores/prompt.js +12 -0
- package/dist/chores/prompt.js.map +1 -0
- package/dist/chores/verdict.d.ts +20 -0
- package/dist/chores/verdict.d.ts.map +1 -0
- package/dist/chores/verdict.js +59 -0
- package/dist/chores/verdict.js.map +1 -0
- package/dist/contracts/agent.contract.d.ts +11 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +2 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.d.ts +54 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/chores.contract.d.ts +151 -0
- package/dist/contracts/chores.contract.d.ts.map +1 -0
- package/dist/contracts/chores.contract.js +8 -0
- package/dist/contracts/chores.contract.js.map +1 -0
- package/dist/contracts/system.contract.d.ts +14 -14
- package/dist/contracts/workspace.contract.d.ts +11 -24
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/events.d.ts +10 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +7 -1
- package/dist/events.js.map +1 -1
- package/dist/hostnames.d.ts +0 -1
- package/dist/hostnames.d.ts.map +1 -1
- package/dist/hostnames.js +0 -1
- package/dist/hostnames.js.map +1 -1
- package/dist/index.d.ts +243 -38
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +590 -40
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +136 -2
- package/dist/schemas.js.map +1 -1
- package/dist/tunnel-ids.d.ts +2 -0
- package/dist/tunnel-ids.d.ts.map +1 -1
- package/dist/tunnel-ids.js +2 -0
- package/dist/tunnel-ids.js.map +1 -1
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +5 -0
- package/dist/workspace-state.js.map +1 -1
- package/package.json +14 -2
- package/src/agent-catalog.test.ts +32 -1
- package/src/agent-catalog.ts +15 -0
- package/src/chores/chores.ts +837 -0
- package/src/chores/digest.test.ts +30 -0
- package/src/chores/digest.ts +0 -0
- package/src/chores/index.ts +9 -0
- package/src/chores/probes.test.ts +166 -0
- package/src/chores/probes.ts +273 -0
- package/src/chores/prompt.ts +64 -0
- package/src/chores/verdict.test.ts +394 -0
- package/src/chores/verdict.ts +167 -0
- package/src/contracts/chores.contract.ts +23 -0
- package/src/events.ts +15 -2
- package/src/hostnames.ts +4 -6
- package/src/index.ts +3 -0
- package/src/schemas.ts +383 -13
- package/src/tunnel-ids.test.ts +49 -0
- package/src/tunnel-ids.ts +24 -0
- package/src/workspace-state.ts +5 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import type { ChoreLedgerEntry, ChorePackage, ChoresReport, ChoreShape, ChoreSignals, ProbeResult } from "../schemas.js";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { choreById, CHORES } from "./chores.js";
|
|
4
|
+
import { assessReport, ledgerKey, unseenVerdicts } from "./verdict.js";
|
|
5
|
+
|
|
6
|
+
/* The state machine, tested at the distinctions it exists to draw. Every case below is one that a simpler design
|
|
7
|
+
* gets wrong in a way that costs the surface its credibility: reporting a repository clean that was never
|
|
8
|
+
* measured, badging the same finding every hour while its fix sits in review, letting a snooze become a
|
|
9
|
+
* permanent silence, or letting an agent's "these were false positives" be forgotten by the next poll. */
|
|
10
|
+
|
|
11
|
+
const DAY = 86_400_000;
|
|
12
|
+
const NOW = Date.UTC(2026, 6, 31);
|
|
13
|
+
|
|
14
|
+
const pkg = (over: Partial<ChorePackage> = {}): ChorePackage => ({
|
|
15
|
+
dir: `_libs/thing`,
|
|
16
|
+
name: `@x/thing`,
|
|
17
|
+
dependencies: [],
|
|
18
|
+
devDependencies: [],
|
|
19
|
+
documented: true,
|
|
20
|
+
...over,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// A repository that is a Node workspace with documents, a pipeline and an image — so every chore APPLIES by
|
|
24
|
+
// default and each applicability test can turn off exactly the one fact it is about.
|
|
25
|
+
const shape = (over: Partial<ChoreShape> = {}): ChoreShape => ({
|
|
26
|
+
docs: [`docs/architecture/repo.md`],
|
|
27
|
+
dockerfiles: [`Dockerfile`],
|
|
28
|
+
ci: [`.github/workflows/ci.yml`],
|
|
29
|
+
lockfile: true,
|
|
30
|
+
packageManifest: true,
|
|
31
|
+
...over,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const signals = (over: Partial<ChoreSignals> = {}): ChoreSignals => ({
|
|
35
|
+
packages: [pkg()],
|
|
36
|
+
shape: shape(),
|
|
37
|
+
hotspots: [],
|
|
38
|
+
keyModules: [],
|
|
39
|
+
totals: { files: 100, symbols: 1000, complexity: 900, hotspots: 0 },
|
|
40
|
+
indexed: true,
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const probe = (over: Partial<ProbeResult> & Pick<ProbeResult, "id">): ProbeResult => ({ state: `ok`, ranAt: NOW - DAY, tookMs: 1000, ...over });
|
|
45
|
+
|
|
46
|
+
const auditProbe = (names: readonly string[]): ProbeResult =>
|
|
47
|
+
probe({
|
|
48
|
+
id: `audit`,
|
|
49
|
+
facts: { id: `audit`, advisories: names.map((name) => ({ name, severity: `high` as const, title: `${name} is bad`, patched: `>=2`, dev: false })) },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const report = (over: Partial<ChoresReport> = {}): ChoresReport => ({ repos: [{ repo: `app`, probes: [], signals: signals() }], ledger: [], node: `v24.18.0`, ...over });
|
|
53
|
+
|
|
54
|
+
const verdictFor = (input: ChoresReport, chore: string, repo = `app`) => {
|
|
55
|
+
const found = assessReport(input, NOW).find((verdict) => verdict.chore.id === chore && verdict.repo === repo);
|
|
56
|
+
if (found === undefined) {
|
|
57
|
+
throw new Error(`no verdict for ${chore} in ${repo}`);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe(`what "we have not measured this" means`, () => {
|
|
63
|
+
test(`a chore whose probe never ran is unavailable, not clear`, () => {
|
|
64
|
+
const verdict = verdictFor(report(), `security-advisories`);
|
|
65
|
+
expect(verdict.state).toBe(`unavailable`);
|
|
66
|
+
expect(verdict.detail).toEqual([`Security advisories · not measured yet`]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The distinction that stops the panel reporting a green repository it has never looked at. A tool the repo
|
|
70
|
+
// does not have is not evidence of anything, and it carries the tool's own reason rather than an invented one.
|
|
71
|
+
test(`a probe the repository cannot run says so, and never badges`, () => {
|
|
72
|
+
const input = report({
|
|
73
|
+
repos: [{ repo: `app`, probes: [probe({ id: `knip`, state: `unavailable`, reason: `knip is not a devDependency` })], signals: signals() }],
|
|
74
|
+
});
|
|
75
|
+
const verdict = verdictFor(input, `dead-code`);
|
|
76
|
+
expect(verdict.state).toBe(`unavailable`);
|
|
77
|
+
expect(verdict.detail[0]).toContain(`knip is not a devDependency`);
|
|
78
|
+
expect(unseenVerdicts([verdict], {})).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test(`a probe that ran and found nothing is clear, with no prompt to spend a turn on`, () => {
|
|
82
|
+
const verdict = verdictFor(report({ repos: [{ repo: `app`, probes: [auditProbe([])], signals: signals() }] }), `security-advisories`);
|
|
83
|
+
expect(verdict.state).toBe(`clear`);
|
|
84
|
+
expect(verdict.prompt).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe(`the ledger debounces; it cannot hide`, () => {
|
|
89
|
+
const withAdvisories = report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`])], signals: signals() }] });
|
|
90
|
+
const ledgerEntry = (over: Partial<ChoreLedgerEntry> = {}): ChoreLedgerEntry => ({
|
|
91
|
+
repo: `app`,
|
|
92
|
+
chore: `security-advisories`,
|
|
93
|
+
ranAt: NOW - DAY,
|
|
94
|
+
runId: `r1`,
|
|
95
|
+
outcome: `acted`,
|
|
96
|
+
digest: verdictFor(withAdvisories, `security-advisories`).digest,
|
|
97
|
+
...over,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test(`a run against this exact evidence leaves the chore due but settled — shown, never badged`, () => {
|
|
101
|
+
const verdict = verdictFor({ ...withAdvisories, ledger: [ledgerEntry()] }, `security-advisories`);
|
|
102
|
+
expect(verdict.state).toBe(`due`);
|
|
103
|
+
expect(verdict.settled).toBe(true);
|
|
104
|
+
expect(unseenVerdicts([verdict], {})).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The point of digesting evidence rather than stamping a time: a fix landing, or a NEW advisory arriving,
|
|
108
|
+
// both move the evidence and both deserve to be heard again.
|
|
109
|
+
test(`evidence that has moved since the run is unsettled again`, () => {
|
|
110
|
+
const moved = report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`, `minimist`])], signals: signals() }], ledger: [ledgerEntry()] });
|
|
111
|
+
const verdict = verdictFor(moved, `security-advisories`);
|
|
112
|
+
expect(verdict.state).toBe(`due`);
|
|
113
|
+
expect(verdict.settled).toBe(false);
|
|
114
|
+
expect(unseenVerdicts([verdict], {})).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test(`an agent reporting the findings did not hold up clears the chore until the evidence changes`, () => {
|
|
118
|
+
const verdict = verdictFor({ ...withAdvisories, ledger: [ledgerEntry({ outcome: `clean` })] }, `security-advisories`);
|
|
119
|
+
expect(verdict.state).toBe(`clear`);
|
|
120
|
+
expect(verdict.headline).toBe(`Checked — the findings did not hold up`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test(`a snooze silences a due chore without hiding it, and lapses on its own`, () => {
|
|
124
|
+
const snoozed = verdictFor({ ...withAdvisories, ledger: [ledgerEntry({ snoozedUntil: NOW + DAY })] }, `security-advisories`);
|
|
125
|
+
expect(snoozed.state).toBe(`snoozed`);
|
|
126
|
+
expect(snoozed.detail).not.toEqual([]);
|
|
127
|
+
expect(unseenVerdicts([snoozed], {})).toEqual([]);
|
|
128
|
+
|
|
129
|
+
const lapsed = verdictFor({ ...withAdvisories, ledger: [ledgerEntry({ snoozedUntil: NOW - 1 })] }, `security-advisories`);
|
|
130
|
+
expect(lapsed.state).toBe(`due`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/* A chore with a cadence expires its own settlement, so "we looked and chose not to act" cannot silence it
|
|
134
|
+
* for good. Security has no cadence on purpose — an advisory does not become interesting again because
|
|
135
|
+
* ninety days passed, it becomes interesting when the advisory set changes — so its settlement persists. */
|
|
136
|
+
test(`settlement expires with the chore's cadence, and persists for the chores that have none`, () => {
|
|
137
|
+
const dependencies = choreById(`dependencies-outdated`);
|
|
138
|
+
expect(dependencies?.cadenceMs).toBeGreaterThan(0);
|
|
139
|
+
expect(choreById(`security-advisories`)?.cadenceMs).toBe(0);
|
|
140
|
+
|
|
141
|
+
const old = { ...withAdvisories, ledger: [ledgerEntry({ ranAt: NOW - 400 * DAY })] };
|
|
142
|
+
expect(verdictFor(old, `security-advisories`).settled).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe(`surveys are due because time passed, and say so`, () => {
|
|
147
|
+
const surveyLedger = (ranAt: number): ChoreLedgerEntry => ({
|
|
148
|
+
repo: `app`,
|
|
149
|
+
chore: `standardize-patterns`,
|
|
150
|
+
ranAt,
|
|
151
|
+
runId: `r1`,
|
|
152
|
+
outcome: `reported`,
|
|
153
|
+
digest: `whatever`,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test(`never run is due`, () => {
|
|
157
|
+
expect(verdictFor(report(), `standardize-patterns`).state).toBe(`due`);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test(`run inside the period is clear, and reports when it was read rather than claiming nothing to do`, () => {
|
|
161
|
+
const verdict = verdictFor({ ...report(), ledger: [surveyLedger(NOW - 10 * DAY)] }, `standardize-patterns`);
|
|
162
|
+
expect(verdict.state).toBe(`clear`);
|
|
163
|
+
expect(verdict.headline).toBe(`Surveyed 10 days ago`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test(`run longer ago than the cadence is due again`, () => {
|
|
167
|
+
expect(verdictFor({ ...report(), ledger: [surveyLedger(NOW - 200 * DAY)] }, `standardize-patterns`).state).toBe(`due`);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe(`the badge speaks about transitions, not about statistics`, () => {
|
|
172
|
+
const withAdvisories = report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`])], signals: signals() }] });
|
|
173
|
+
|
|
174
|
+
test(`acknowledging a digest silences it, and the next distinct finding still gets through`, () => {
|
|
175
|
+
const first = verdictFor(withAdvisories, `security-advisories`);
|
|
176
|
+
expect(unseenVerdicts([first], {})).toHaveLength(1);
|
|
177
|
+
|
|
178
|
+
const seen = { [ledgerKey(`app`, `security-advisories`)]: first.digest };
|
|
179
|
+
expect(unseenVerdicts([first], seen)).toEqual([]);
|
|
180
|
+
|
|
181
|
+
const next = verdictFor(report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`, `tar`])], signals: signals() }] }), `security-advisories`);
|
|
182
|
+
expect(unseenVerdicts([next], seen)).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// The rule the whole surface is built to satisfy: a backlog that has been seen once must stop speaking, or
|
|
186
|
+
// the tile is lit every day and the rail stops being read at all.
|
|
187
|
+
test(`a standing backlog of undocumented packages goes quiet once seen, but a new package speaks`, () => {
|
|
188
|
+
const undocumented = (dirs: readonly string[]): ChoresReport =>
|
|
189
|
+
report({
|
|
190
|
+
repos: [{ repo: `app`, probes: [], signals: signals({ packages: dirs.map((dir) => pkg({ dir, name: dir, documented: false })) }) }],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const backlog = verdictFor(undocumented([`_libs/a`, `_libs/b`]), `documentation-refresh`);
|
|
194
|
+
expect(backlog.state).toBe(`due`);
|
|
195
|
+
const seen = { [ledgerKey(`app`, `documentation-refresh`)]: backlog.digest };
|
|
196
|
+
expect(unseenVerdicts([backlog], seen)).toEqual([]);
|
|
197
|
+
|
|
198
|
+
const grown = verdictFor(undocumented([`_libs/a`, `_libs/b`, `_libs/new`]), `documentation-refresh`);
|
|
199
|
+
expect(unseenVerdicts([grown], seen)).toHaveLength(1);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe(`the findings themselves`, () => {
|
|
204
|
+
test(`complexity reports only files that are load-bearing or out of proportion, never just the ranking's top`, () => {
|
|
205
|
+
const hotspot = (path: string, complexity: number) => ({ path, commits: 20, adds: 100, dels: 50, complexity, score: complexity * 20, latestMs: NOW });
|
|
206
|
+
// An even ranking has no outlier and no key module in it: a healthy repository, and an empty finding.
|
|
207
|
+
const even = report({
|
|
208
|
+
repos: [{ repo: `app`, probes: [], signals: signals({ hotspots: [hotspot(`a.ts`, 30), hotspot(`b.ts`, 28), hotspot(`c.ts`, 26)] }) }],
|
|
209
|
+
});
|
|
210
|
+
expect(verdictFor(even, `complexity`).state).toBe(`clear`);
|
|
211
|
+
|
|
212
|
+
const outlier = report({
|
|
213
|
+
repos: [{ repo: `app`, probes: [], signals: signals({ hotspots: [hotspot(`a.ts`, 200), hotspot(`b.ts`, 28), hotspot(`c.ts`, 26)] }) }],
|
|
214
|
+
});
|
|
215
|
+
const verdict = verdictFor(outlier, `complexity`);
|
|
216
|
+
expect(verdict.state).toBe(`due`);
|
|
217
|
+
expect(verdict.detail).toHaveLength(1);
|
|
218
|
+
expect(verdict.detail[0]).toContain(`a.ts`);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test(`a half-built index says nothing rather than ranking what it has read so far`, () => {
|
|
222
|
+
const partial = report({
|
|
223
|
+
repos: [
|
|
224
|
+
{
|
|
225
|
+
repo: `app`,
|
|
226
|
+
probes: [],
|
|
227
|
+
signals: signals({ indexed: false, hotspots: [{ path: `a.ts`, commits: 9, adds: 1, dels: 1, complexity: 900, score: 8100, latestMs: NOW }] }),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
expect(verdictFor(partial, `complexity`).state).toBe(`clear`);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test(`two libraries for one job is the finding; one is not`, () => {
|
|
235
|
+
const one = report({ repos: [{ repo: `app`, probes: [], signals: signals({ packages: [pkg({ dependencies: [`zod`] })] }) }] });
|
|
236
|
+
expect(verdictFor(one, `library-overlap`).state).toBe(`clear`);
|
|
237
|
+
|
|
238
|
+
const two = report({ repos: [{ repo: `app`, probes: [], signals: signals({ packages: [pkg({ dependencies: [`zod`, `yup`] })] }) }] });
|
|
239
|
+
const verdict = verdictFor(two, `library-overlap`);
|
|
240
|
+
expect(verdict.state).toBe(`due`);
|
|
241
|
+
expect(verdict.detail[0]).toContain(`schema validation`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// The runtime table is static and offline by design; a major it does not know about must read as "not
|
|
245
|
+
// end-of-life", which is the safe direction to be wrong in.
|
|
246
|
+
test(`an unknown node major is not reported as end-of-life`, () => {
|
|
247
|
+
expect(verdictFor({ ...report(), node: `v99.0.0` }, `runtime-eol`).state).toBe(`clear`);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test(`a runtime past its end-of-life date is the one ordinary finding that reaches warning`, () => {
|
|
251
|
+
const verdict = verdictFor({ ...report(), node: `v18.20.0` }, `runtime-eol`);
|
|
252
|
+
expect(verdict.state).toBe(`due`);
|
|
253
|
+
expect(verdict.severity).toBe(`warning`);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test(`a supported runtime is clear`, () => {
|
|
257
|
+
expect(verdictFor({ ...report(), node: `v24.18.0` }, `runtime-eol`).state).toBe(`clear`);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe(`the prompts`, () => {
|
|
262
|
+
const dueVerdict = () => verdictFor(report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`])], signals: signals() }] }), `security-advisories`);
|
|
263
|
+
|
|
264
|
+
/* A prompt that counts without NAMING sends the agent off to re-derive a list we are already holding — slowly,
|
|
265
|
+
* and against a tree that has moved since. Every measured chore names its artefacts. */
|
|
266
|
+
test(`name the artefacts, not just how many there were`, () => {
|
|
267
|
+
const verdict = dueVerdict();
|
|
268
|
+
expect(verdict.prompt).toContain(`left-pad`);
|
|
269
|
+
expect(verdict.prompt).toContain(`app`);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test(`tell an acting chore to keep the diff reviewable and a reporting chore not to edit at all`, () => {
|
|
273
|
+
expect(dueVerdict().prompt).toContain(`separately explainable`);
|
|
274
|
+
const survey = verdictFor(report(), `standardize-patterns`);
|
|
275
|
+
expect(survey.prompt).toContain(`Change nothing.`);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test(`every chore that can be due can produce a prompt`, () => {
|
|
279
|
+
const verdicts = assessReport(
|
|
280
|
+
report({
|
|
281
|
+
repos: [
|
|
282
|
+
{
|
|
283
|
+
repo: `app`,
|
|
284
|
+
probes: [
|
|
285
|
+
auditProbe([`left-pad`]),
|
|
286
|
+
probe({ id: `outdated`, facts: { id: `outdated`, packages: [{ name: `vue`, current: `1.0.0`, latest: `2.0.0`, kind: `major`, section: `dependencies` }] } }),
|
|
287
|
+
probe({ id: `knip`, facts: { id: `knip`, deadCode: { files: 3, exports: 2, types: 0, dependencies: 1, devDependencies: 0, sample: [`a.ts`] } } }),
|
|
288
|
+
probe({ id: `jscpd`, facts: { id: `jscpd`, duplication: { percentage: 9, clones: 4, top: [{ lines: 20, first: `a.ts`, second: `b.ts` }] } } }),
|
|
289
|
+
],
|
|
290
|
+
signals: signals({ packages: [pkg({ documented: false, dependencies: [`zod`, `joi`] })] }),
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
node: `v18.20.0`,
|
|
294
|
+
}),
|
|
295
|
+
NOW,
|
|
296
|
+
);
|
|
297
|
+
const due = verdicts.filter((verdict) => verdict.state === `due`);
|
|
298
|
+
expect(due.length).toBeGreaterThanOrEqual(8);
|
|
299
|
+
for (const verdict of due) {
|
|
300
|
+
expect(verdict.prompt, verdict.chore.id).toBeTypeOf(`string`);
|
|
301
|
+
expect(verdict.digest, verdict.chore.id).not.toBe(``);
|
|
302
|
+
}
|
|
303
|
+
// Each measured chore's own artefact reaches its own prompt — the regression this whole test exists for.
|
|
304
|
+
const promptFor = (chore: string) => due.find((verdict) => verdict.chore.id === chore)?.prompt ?? ``;
|
|
305
|
+
expect(promptFor(`security-advisories`)).toContain(`left-pad`);
|
|
306
|
+
expect(promptFor(`dependencies-outdated`)).toContain(`vue 1.0.0 → 2.0.0`);
|
|
307
|
+
expect(promptFor(`dead-code`)).toContain(`a.ts`);
|
|
308
|
+
expect(promptFor(`duplication`)).toContain(`a.ts ↔ b.ts`);
|
|
309
|
+
expect(promptFor(`documentation-refresh`)).toContain(`_libs/thing`);
|
|
310
|
+
expect(promptFor(`library-overlap`)).toContain(`zod`);
|
|
311
|
+
expect(promptFor(`runtime-eol`)).toContain(`v18.20.0`);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/* APPLICABILITY — whether the chore is a QUESTION worth asking of this repository, as opposed to whether the
|
|
316
|
+
* answer is yes. Every case here is one where the previous design showed a row that could never be acted on:
|
|
317
|
+
* an offer to re-read documentation that was never written, to slim an image that does not exist, to tighten a
|
|
318
|
+
* pipeline nobody has. Each of those teaches the reader that this list was not written by someone who looked. */
|
|
319
|
+
describe(`what does not apply here`, () => {
|
|
320
|
+
const withShape = (over: Partial<ChoreShape>): ChoresReport =>
|
|
321
|
+
report({ repos: [{ repo: `app`, probes: [], signals: signals({ shape: shape(over) }) }] });
|
|
322
|
+
|
|
323
|
+
test(`a repository with no documents is not asked to re-read its documentation`, () => {
|
|
324
|
+
const verdict = verdictFor(withShape({ docs: [] }), `documentation-drift`);
|
|
325
|
+
expect(verdict.state).toBe(`not-applicable`);
|
|
326
|
+
expect(verdict.headline).toBe(`this repository has no architecture documents to re-read`);
|
|
327
|
+
expect(verdict.prompt).toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test(`a repository with no Dockerfile is not asked to slim its image`, () => {
|
|
331
|
+
expect(verdictFor(withShape({ dockerfiles: [] }), `docker-image`).state).toBe(`not-applicable`);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test(`a repository with no pipeline is not asked to tighten one`, () => {
|
|
335
|
+
expect(verdictFor(withShape({ ci: [] }), `ci-hygiene`).state).toBe(`not-applicable`);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test(`a repository that is not a Node project is not offered the npm-shaped chores`, () => {
|
|
339
|
+
const foreign = withShape({ packageManifest: false, lockfile: false });
|
|
340
|
+
for (const chore of [`dependencies-outdated`, `runtime-eol`, `dead-code`, `security-advisories`, `deprecated-apis`]) {
|
|
341
|
+
expect(verdictFor(foreign, chore).state, chore).toBe(`not-applicable`);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test(`a repository that is not a workspace is not asked about per-package documents or library overlap`, () => {
|
|
346
|
+
const single = report({ repos: [{ repo: `app`, probes: [], signals: signals({ packages: [] }) }] });
|
|
347
|
+
expect(verdictFor(single, `documentation-refresh`).state).toBe(`not-applicable`);
|
|
348
|
+
expect(verdictFor(single, `library-overlap`).state).toBe(`not-applicable`);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// A survey has no evidence to be absent — "90 days have passed" is true everywhere — so without a gate it
|
|
352
|
+
// fires forever in repositories where its subject does not exist. This is the regression that motivated
|
|
353
|
+
// making `applies` a required field on SurveySpec rather than an optional one.
|
|
354
|
+
test(`a tiny repository is not surveyed for cross-cutting patterns it cannot have`, () => {
|
|
355
|
+
const tiny = report({ repos: [{ repo: `app`, probes: [], signals: signals({ totals: { files: 4, symbols: 10, complexity: 5, hotspots: 0 } }) }] });
|
|
356
|
+
const verdict = verdictFor(tiny, `standardize-patterns`);
|
|
357
|
+
expect(verdict.state).toBe(`not-applicable`);
|
|
358
|
+
expect(verdict.headline).toContain(`4 indexed files`);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test(`applicability is decided before measurement, so a missing probe never masks it`, () => {
|
|
362
|
+
// dead-code needs the knip probe, which has not run; the gate still wins, because "we cannot ask this
|
|
363
|
+
// question here" outranks "we have not measured it".
|
|
364
|
+
expect(verdictFor(withShape({ packageManifest: false }), `dead-code`).state).toBe(`not-applicable`);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test(`a chore that does not apply can never reach the rail`, () => {
|
|
368
|
+
const verdicts = assessReport(withShape({ docs: [], dockerfiles: [], ci: [] }), NOW).filter((verdict) => verdict.state === `not-applicable`);
|
|
369
|
+
expect(verdicts.length).toBeGreaterThanOrEqual(3);
|
|
370
|
+
expect(unseenVerdicts(verdicts, {})).toEqual([]);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test(`a fully-equipped repository rules nothing out`, () => {
|
|
374
|
+
expect(assessReport(withShape({}), NOW).filter((verdict) => verdict.state === `not-applicable`)).toEqual([]);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/* THE CRITERION — the rule in words, next to the evidence that met it. A row that reports a number without the
|
|
379
|
+
* rule behind it is asking to be taken on trust, and the first row that turns out to be wrong costs the whole
|
|
380
|
+
* list its credibility. */
|
|
381
|
+
describe(`every chore says what would make it due`, () => {
|
|
382
|
+
test(`every entry in the book carries a criterion`, () => {
|
|
383
|
+
for (const chore of CHORES) {
|
|
384
|
+
expect(chore.criterion, chore.id).toBeTypeOf(`string`);
|
|
385
|
+
expect(chore.criterion.length, chore.id).toBeGreaterThan(20);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test(`the criterion reaches the prompt, so the agent can tell us the rule was wrong`, () => {
|
|
390
|
+
const due = verdictFor(report({ repos: [{ repo: `app`, probes: [auditProbe([`left-pad`])], signals: signals() }] }), `security-advisories`);
|
|
391
|
+
expect(due.prompt).toContain(`You were woken because:`);
|
|
392
|
+
expect(due.prompt).toContain(due.chore.criterion);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { ChoreLedgerEntry, ChoresReport, ProbeId, ProbeResult } from "../schemas.js";
|
|
2
|
+
import { type Chore, type ChoreContext, type ChoreFinding, CHORES, chorePrompt } from "./chores.js";
|
|
3
|
+
import { probeSpec } from "./probes.js";
|
|
4
|
+
|
|
5
|
+
/* FROM EVIDENCE TO A VERDICT — the one place that decides whether a chore is due, and the only place that is
|
|
6
|
+
* allowed to. Both the Maintenance panel and its rail badge run this function over the same report, so the number
|
|
7
|
+
* on the tile and the reason in the panel are the same computation and cannot drift apart.
|
|
8
|
+
*
|
|
9
|
+
* Five states, and the distinctions between them are the whole design:
|
|
10
|
+
*
|
|
11
|
+
* not-applicable this chore is not a QUESTION worth asking of this repository — there is no Dockerfile to
|
|
12
|
+
* slim, no pipeline to tighten, no documentation to re-read. Dropped from the panel entirely
|
|
13
|
+
* rather than shown as clear, because "clear" claims we checked, and there was nothing to check.
|
|
14
|
+
* The reason survives as a footer, so "why is there no Docker chore here?" has an answer.
|
|
15
|
+
* unavailable we have not measured this. knip is not a devDependency; there is no lockfile to audit. Rendered
|
|
16
|
+
* greyed, never badged, and never collapsed into `clear` — a maintenance surface reporting a green
|
|
17
|
+
* repository it has never actually measured is worse than one that says nothing.
|
|
18
|
+
* clear we measured, and there is nothing to do. This is the common state, and it has to be visibly
|
|
19
|
+
* reachable or the panel is just a list of complaints.
|
|
20
|
+
* snoozed the owner said "not now". Still listed, still showing its evidence, silent until it lapses.
|
|
21
|
+
* due there is something to do.
|
|
22
|
+
*
|
|
23
|
+
* The first three are all ways of saying "no", and keeping them apart is what makes the surface trustworthy: they
|
|
24
|
+
* mean we cannot ask, we did not measure, and we measured and found nothing — three different claims, and only
|
|
25
|
+
* the last one is reassurance.
|
|
26
|
+
*
|
|
27
|
+
* And one flag that is not a state: `settled`. A due chore whose evidence is UNCHANGED since a turn was already
|
|
28
|
+
* spent on it stays due — because it is — but must never light the rail again. This is what stops the surface
|
|
29
|
+
* repeating itself while a fix sits in review, and it is why the ledger stores a digest rather than a timestamp:
|
|
30
|
+
* "ran 3 days ago" cannot tell you whether it ran against THIS.
|
|
31
|
+
*
|
|
32
|
+
* Nothing here can hide a problem. Snoozing and settling change whether the rail SPEAKS; the panel still shows
|
|
33
|
+
* the chore, its evidence and its state. The one thing that removes a row entirely is `not-applicable`, and that
|
|
34
|
+
* is not hiding — it is the absence of a subject, recorded in the panel's footer with its reason. A maintenance
|
|
35
|
+
* surface you can quietly bury findings in is a maintenance surface nobody trusts. */
|
|
36
|
+
|
|
37
|
+
export type ChoreState = "due" | "clear" | "snoozed" | "unavailable" | "not-applicable";
|
|
38
|
+
|
|
39
|
+
export interface ChoreVerdict {
|
|
40
|
+
readonly chore: Chore;
|
|
41
|
+
readonly repo: string;
|
|
42
|
+
readonly state: ChoreState;
|
|
43
|
+
readonly severity: ChoreFinding["severity"];
|
|
44
|
+
// Always present, in every state — "nothing to do" and "not measured" are answers a reader deserves in words.
|
|
45
|
+
readonly headline: string;
|
|
46
|
+
readonly detail: readonly string[];
|
|
47
|
+
// The evidence identity. Empty for `unavailable`, where there is no evidence to identify.
|
|
48
|
+
readonly digest: string;
|
|
49
|
+
// The turn. Present only when there is something to do — a "start an agent" button on a clear chore is an
|
|
50
|
+
// invitation to spend money proving that nothing is wrong.
|
|
51
|
+
readonly prompt: string | undefined;
|
|
52
|
+
readonly lastRun: ChoreLedgerEntry | undefined;
|
|
53
|
+
// A turn has already been spent on exactly this evidence, and the chore's cadence has not lapsed since. Still
|
|
54
|
+
// due, still shown, never badged.
|
|
55
|
+
readonly settled: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A survey that is clear is clear because it was READ recently, and saying so is the only way its row means
|
|
59
|
+
// anything — "nothing to do" under a chore that has no measurement would be a claim about the code rather than
|
|
60
|
+
// about the calendar.
|
|
61
|
+
const clearHeadline = (chore: Chore, lastRun: ChoreLedgerEntry | undefined, nowMs: number): string =>
|
|
62
|
+
chore.survey === true && lastRun !== undefined ? `Surveyed ${Math.round((nowMs - lastRun.ranAt) / 86_400_000)} days ago` : `Nothing to do`;
|
|
63
|
+
|
|
64
|
+
// Why a chore could not be assessed, in the words of the thing that could not do it. Never invented: an
|
|
65
|
+
// `unavailable` probe carries the tool's own reason, and a probe that has simply not run yet says that.
|
|
66
|
+
const unmeasuredDetail = (needs: readonly ProbeId[], probes: ReadonlyMap<ProbeId, ProbeResult>): string[] =>
|
|
67
|
+
needs.flatMap((id) => {
|
|
68
|
+
const probe = probes.get(id);
|
|
69
|
+
const spec = probeSpec(id);
|
|
70
|
+
if (probe === undefined) {
|
|
71
|
+
return [`${spec.title} · not measured yet`];
|
|
72
|
+
}
|
|
73
|
+
if (probe.state === `ok`) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
return [`${spec.title} · ${probe.state === `unavailable` ? `not available in this repository` : `failed`}${probe.reason === undefined ? `` : ` — ${probe.reason}`}`];
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const assessChore = (chore: Chore, context: ChoreContext, ledger: ChoreLedgerEntry | undefined): ChoreVerdict => {
|
|
80
|
+
const base = { chore, repo: context.repo, lastRun: ledger, settled: false, prompt: undefined } as const;
|
|
81
|
+
|
|
82
|
+
/* APPLICABILITY FIRST, before anything is measured or any evidence is read. A chore that does not apply is
|
|
83
|
+
* not "clear" and not "unmeasured" — the question does not arise here, and every subsequent branch of this
|
|
84
|
+
* function would be answering it anyway. The reason is carried as the headline, because the panel's footer
|
|
85
|
+
* is the only place it will ever be read. */
|
|
86
|
+
const inapplicable = chore.applies?.(context.signals);
|
|
87
|
+
if (inapplicable !== undefined) {
|
|
88
|
+
return { ...base, state: `not-applicable`, severity: `info`, headline: inapplicable, detail: [], digest: `` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const unmeasured = unmeasuredDetail(chore.needs, context.probes);
|
|
92
|
+
if (unmeasured.length > 0) {
|
|
93
|
+
return { ...base, state: `unavailable`, severity: `info`, headline: `Not measured`, detail: unmeasured, digest: `` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const finding = chore.assess(context);
|
|
97
|
+
if (finding === undefined) {
|
|
98
|
+
return { ...base, state: `clear`, severity: `info`, headline: clearHeadline(chore, ledger, context.nowMs), detail: [], digest: `` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Has the last run's settlement lapsed? A cadence of 0 means "this is decided by evidence alone" — an advisory
|
|
102
|
+
* does not become worth looking at again because ninety days passed, it becomes worth looking at again when
|
|
103
|
+
* the advisory set changes. Anything with a cadence expires its own settlement, so "we looked and chose not to
|
|
104
|
+
* act" cannot silence a chore for good. */
|
|
105
|
+
const lapsed = ledger !== undefined && chore.cadenceMs > 0 && context.nowMs - ledger.ranAt >= chore.cadenceMs;
|
|
106
|
+
const sameEvidence = ledger?.digest === finding.digest && !lapsed;
|
|
107
|
+
|
|
108
|
+
/* A SURVEY has no measurement, so the calendar is the whole trigger: it is due because it has been that long,
|
|
109
|
+
* and a run inside the current period settles it until the next one begins. Checked against the run's TIME
|
|
110
|
+
* rather than its digest, because a survey run three days into a quarter and one three days before its end
|
|
111
|
+
* are the same period but very different answers to "when was this last read?". */
|
|
112
|
+
if (chore.survey === true && ledger !== undefined && context.nowMs - ledger.ranAt < chore.cadenceMs) {
|
|
113
|
+
return { ...base, state: `clear`, severity: `info`, headline: clearHeadline(chore, ledger, context.nowMs), detail: finding.detail, digest: finding.digest };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const prompt = chorePrompt(chore, finding, context.repo);
|
|
117
|
+
|
|
118
|
+
if (ledger?.snoozedUntil !== undefined && ledger.snoozedUntil > context.nowMs) {
|
|
119
|
+
return { ...base, state: `snoozed`, severity: `info`, headline: finding.headline, detail: finding.detail, digest: finding.digest, prompt };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* The agent looked at exactly this evidence and reported that there was nothing in it — knip's findings were
|
|
123
|
+
* all public entry points, the clones were all generated files. That verdict has to stick, or the next poll
|
|
124
|
+
* starts the same turn again and the surface has taught the owner that its rows are wrong. It stops sticking
|
|
125
|
+
* when the evidence changes (a different digest) or the cadence lapses. */
|
|
126
|
+
if (sameEvidence && ledger?.outcome === `clean`) {
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
state: `clear`,
|
|
130
|
+
severity: `info`,
|
|
131
|
+
headline: `Checked — the findings did not hold up`,
|
|
132
|
+
detail: finding.detail,
|
|
133
|
+
digest: finding.digest,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { ...base, state: `due`, severity: finding.severity, headline: finding.headline, detail: finding.detail, digest: finding.digest, prompt, settled: sameEvidence };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// The ledger is keyed by repo + chore, which is the grain a verdict is decided at: the same chore in two repos is
|
|
141
|
+
// two independent questions with two independent answers.
|
|
142
|
+
export const ledgerKey = (repo: string, chore: string): string => `${repo}|${chore}`;
|
|
143
|
+
|
|
144
|
+
/* Every chore in every repo, from one report. This is what both surfaces call — the panel groups the result, the
|
|
145
|
+
* badge filters it — so there is exactly one traversal of the book in the codebase and adding a chore to CHORES
|
|
146
|
+
* reaches both surfaces without touching either. */
|
|
147
|
+
export const assessReport = (report: ChoresReport, nowMs: number): ChoreVerdict[] => {
|
|
148
|
+
const ledger = new Map(report.ledger.map((entry) => [ledgerKey(entry.repo, entry.chore), entry]));
|
|
149
|
+
return report.repos.flatMap(({ repo, probes, signals }) => {
|
|
150
|
+
const context: ChoreContext = { repo, probes: new Map(probes.map((probe) => [probe.id, probe])), signals, node: report.node, nowMs };
|
|
151
|
+
return CHORES.map((chore) => assessChore(chore, context, ledger.get(ledgerKey(repo, chore.id))));
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/* WHAT THE RAIL IS ALLOWED TO SAY. A badge must mean "something happened here that you don't already know about",
|
|
156
|
+
* never "here is a statistic" — the extension API states that bar and this is the function that holds this
|
|
157
|
+
* surface to it. Three filters, and every one of them removes a case that would otherwise light the tile forever:
|
|
158
|
+
*
|
|
159
|
+
* state === due the obvious one.
|
|
160
|
+
* !settled a turn has already been spent on this exact evidence.
|
|
161
|
+
* unseen digest the owner has already LOOKED at this evidence in the panel. Acknowledgement is per digest
|
|
162
|
+
* rather than per chore, so acknowledging today's finding does not also swallow tomorrow's.
|
|
163
|
+
*
|
|
164
|
+
* `seen` maps ledgerKey → the digest last acknowledged. It lives in a file beside the ledger, because the badge is
|
|
165
|
+
* derived from files and its acknowledgement belongs in the same tree. */
|
|
166
|
+
export const unseenVerdicts = (verdicts: readonly ChoreVerdict[], seen: Readonly<Record<string, string>>): ChoreVerdict[] =>
|
|
167
|
+
verdicts.filter((verdict) => verdict.state === `due` && !verdict.settled && seen[ledgerKey(verdict.repo, verdict.chore.id)] !== verdict.digest);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { oc } from "@orpc/contract";
|
|
2
|
+
import { ChoreLedgerWriteSchema, ChoreProbeRequestSchema, ChoresReportSchema, OkSchema } from "../schemas.js";
|
|
3
|
+
|
|
4
|
+
/* Maintenance evidence: what every repo under /work currently measures, and what has already been done about it.
|
|
5
|
+
* Three routes, because there are exactly three things the surface does — read the evidence, ask for a
|
|
6
|
+
* measurement to be retaken, and record what a turn concluded.
|
|
7
|
+
*
|
|
8
|
+
* There is no `GET /chores/{id}` and no "run this chore" route on purpose. A chore RUN is an ordinary isolated
|
|
9
|
+
* fleet agent (`POST /agent` with a derived conversation id), the same as an acceptance run or a documentation
|
|
10
|
+
* generation — so the worktree, the live status, the cost, the transcript and the /agents/<id> page already
|
|
11
|
+
* exist, and adding a bespoke launcher here would be a second way to start a turn that has to be kept in step
|
|
12
|
+
* with the first. */
|
|
13
|
+
export const choresContract = {
|
|
14
|
+
// Every repo's standing evidence in one read: cached probe results (with their age and state), the cheap
|
|
15
|
+
// resident signals, the ledger, and the daemon's node version. The rail badge polls this; so does the panel.
|
|
16
|
+
list: oc.route({ method: "GET", path: "/chores" }).output(ChoresReportSchema),
|
|
17
|
+
// Re-run one repo's probe now, ignoring its TTL — the panel's per-probe refresh. An ack: the runner works in
|
|
18
|
+
// the background and the result arrives on the next `list`, because a jscpd sweep outlives any sane request.
|
|
19
|
+
probe: oc.route({ method: "POST", path: "/chores/probe" }).input(ChoreProbeRequestSchema).output(OkSchema),
|
|
20
|
+
// Record what a chore turn concluded, or snooze one. Upsert by repo+chore: a chore has one current verdict,
|
|
21
|
+
// and a growing history of "we looked at this and it was fine" is not something any reader wants paged.
|
|
22
|
+
record: oc.route({ method: "POST", path: "/chores/ledger" }).input(ChoreLedgerWriteSchema).output(OkSchema),
|
|
23
|
+
};
|
package/src/events.ts
CHANGED
|
@@ -192,8 +192,21 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
192
192
|
* bind-mounted over the workspace root and the harness is rewriting tool paths into it instead. That
|
|
193
193
|
* fallback covers what arrives as tool input and not what a subprocess computes for itself, so the
|
|
194
194
|
* operator needs to know — this state used to be one line in the daemon log at boot, and the way it got
|
|
195
|
-
* noticed was files appearing in the main tree from agents that were supposed to be on branches.
|
|
196
|
-
|
|
195
|
+
* noticed was files appearing in the main tree from agents that were supposed to be on branches.
|
|
196
|
+
*
|
|
197
|
+
* `sync` reports the pre-turn rebase (agents/sync.ts), and rides here because this frame is already the
|
|
198
|
+
* turn's "where you are standing" announcement. Present only when the branch was BEHIND the main line —
|
|
199
|
+
* `commits` is how many main-line commits it gained, `blocked` names the repos whose rebase would not apply
|
|
200
|
+
* and was rolled back. Both can be non-empty at once in a multi-repo composition. It is a notice and never
|
|
201
|
+
* a question: the user is answering their agent, and the alternative to rebasing is not "stay safe" but
|
|
202
|
+
* "conflict at land time", which interrupts them harder. */
|
|
203
|
+
z.object({
|
|
204
|
+
kind: z.literal("worktree"),
|
|
205
|
+
branch: z.string(),
|
|
206
|
+
base: z.string(),
|
|
207
|
+
unenforced: z.boolean().optional(),
|
|
208
|
+
sync: z.object({ commits: z.number(), blocked: z.array(z.string()) }).optional(),
|
|
209
|
+
}),
|
|
197
210
|
// Emitted after a clean isolated turn whose delta auto-landed (or failed to): landed ⇒ the work is now
|
|
198
211
|
// UNCOMMITTED changes in the main tree (the Changes panel is the review); conflicts ⇒ it stayed safely in
|
|
199
212
|
// the worktree, and each named path carries WHY it would not apply (see LandConflictSchema) so the report
|
package/src/hostnames.ts
CHANGED
|
@@ -43,18 +43,16 @@ export const CATCH_ALL = { service: "http_status:404" } as const;
|
|
|
43
43
|
// covers exactly one level), where <panel> is `<repo>` or `<repo>--<app>` and <sandboxId> pins the hostname to
|
|
44
44
|
// this sandbox (the shared intentic zone hosts many sandboxes; without the id two users' panels would collide).
|
|
45
45
|
// Port-forward scheme: `port-<slot>-<sandboxId>.<zone>` — the same shape with a `port-` prefix, where <slot>
|
|
46
|
-
// is one of the sandbox's
|
|
47
|
-
// intentic-provided path's minted routes bounded and warm while dev servers churn ephemeral
|
|
46
|
+
// is one of the sandbox's forward slots (portSlotsFromToken in ./tunnel-ids), not the port number itself:
|
|
47
|
+
// slots keep the intentic-provided path's minted routes bounded and warm while dev servers churn ephemeral
|
|
48
|
+
// ports. The slot labels are salted with the connect token rather than being the letters a…h, so a forwarded
|
|
49
|
+
// port's hostname is not derivable from the (public) sandbox id alone — see tunnel-ids for why that matters.
|
|
48
50
|
//
|
|
49
51
|
// A *label* is the first-DNS-label prefix before `-<sandboxId>` (`preview-<panel>` / `port-<slot>`) — the unit
|
|
50
52
|
// the platform's /sandbox/preview-route mints, so one endpoint serves both schemes.
|
|
51
53
|
export const previewLabel = (panel: string): string => `preview-${panel}`;
|
|
52
54
|
export const portLabel = (slot: string): string => `port-${slot}`;
|
|
53
55
|
|
|
54
|
-
// The fixed per-sandbox forward slots. Eight is deliberate: enough for a monorepo's worth of concurrent dev
|
|
55
|
-
// servers, and the hard cap on preview DNS records a sandbox can ever cost the shared intentic zone.
|
|
56
|
-
export const PORT_SLOTS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
|
|
57
|
-
|
|
58
56
|
// The hostname a label resolves to — what the platform's /sandbox/preview-route mints from the label alone.
|
|
59
57
|
export const labelHostname = (label: string, id: string, zone: string): string => `${label}-${id}.${zone}`;
|
|
60
58
|
export const previewHostname = (panel: string, id: string, zone: string): string => labelHostname(previewLabel(panel), id, zone);
|