@intentic/sandbox-contract 1.252.1 → 1.254.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/contracts/prepush.contract.d.ts +22 -16
- package/dist/contracts/prepush.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.js +12 -1
- package/dist/contracts/prepush.contract.js.map +1 -1
- package/dist/contracts/settings.contract.d.ts +38 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.js +18 -0
- package/dist/contracts/settings.contract.js.map +1 -1
- package/dist/contracts/system.contract.d.ts +30 -30
- package/dist/index.d.ts +72 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol/peer-dial.d.ts +2 -1
- package/dist/protocol/peer-dial.d.ts.map +1 -1
- package/dist/protocol/peer-dial.js +29 -2
- package/dist/protocol/peer-dial.js.map +1 -1
- package/dist/schemas/devices.d.ts +14 -0
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +7 -0
- package/dist/schemas/devices.js.map +1 -1
- package/dist/schemas/repo-checks.d.ts +74 -0
- package/dist/schemas/repo-checks.d.ts.map +1 -0
- package/dist/schemas/repo-checks.js +36 -0
- package/dist/schemas/repo-checks.js.map +1 -0
- package/dist/schemas/settings.d.ts +31 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +17 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/state/definition.d.ts +4 -0
- package/dist/state/definition.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/contracts/prepush.contract.ts +18 -1
- package/src/contracts/settings.contract.ts +22 -0
- package/src/index.ts +1 -0
- package/src/protocol/peer-dial.test.ts +73 -2
- package/src/protocol/peer-dial.ts +61 -5
- package/src/schemas/devices.ts +24 -3
- package/src/schemas/repo-checks.ts +64 -0
- package/src/schemas/settings.ts +28 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { oc } from "@orpc/contract";
|
|
2
|
+
import { RepoChecksAdoptSchema, RepoChecksListSchema } from "../schemas/repo-checks.js";
|
|
2
3
|
import { BuiltinPromptSchema, BuiltinPromptTextSchema, RuleFiringsSchema, SandboxSettingsSchema, SavingsReportSchema } from "../schemas/settings.js";
|
|
3
4
|
import { OkSchema } from "../schemas/shared.js";
|
|
4
5
|
import { DayWindowQuerySchema } from "../schemas/usage.js";
|
|
@@ -54,4 +55,25 @@ export const settingsContract = {
|
|
|
54
55
|
"A separate read rather than a field on the settings, because a rule firing is not somebody editing anything: folding it in would turn every firing into a settings write and put a self-changing value inside the object a screen edits.",
|
|
55
56
|
})
|
|
56
57
|
.output(RuleFiringsSchema),
|
|
58
|
+
// Read off the repositories themselves, not out of the settings file: the declaration is a tracked file in each
|
|
59
|
+
// repository, and only the owner's answer to it lives here.
|
|
60
|
+
repoChecks: oc
|
|
61
|
+
.route({
|
|
62
|
+
method: "GET",
|
|
63
|
+
path: "/settings/repo-checks",
|
|
64
|
+
summary: "What each repository asks to run on its own code",
|
|
65
|
+
description:
|
|
66
|
+
"Every repository that declares its own checks at `.intentic/checks.json`, what it declares, and whether you have switched it on. A repository declares what to run because the command belongs beside the scripts it names; nothing it declares runs until you say so.",
|
|
67
|
+
})
|
|
68
|
+
.output(RepoChecksListSchema),
|
|
69
|
+
adoptRepoChecks: oc
|
|
70
|
+
.route({
|
|
71
|
+
method: "POST",
|
|
72
|
+
path: "/settings/repo-checks/adopt",
|
|
73
|
+
summary: "Switch a repository's own checks on or off",
|
|
74
|
+
description:
|
|
75
|
+
"Adopts exactly what that repository declares as it stands now. If the declaration changes afterwards it stops running until you adopt it again, so a command nobody has read cannot inherit the answer given to a different one.",
|
|
76
|
+
})
|
|
77
|
+
.input(RepoChecksAdoptSchema)
|
|
78
|
+
.output(OkSchema),
|
|
57
79
|
};
|
package/src/index.ts
CHANGED
|
@@ -168,6 +168,7 @@ export * from "./schemas/provider-oauth.js";
|
|
|
168
168
|
export * from "./schemas/provider-subscriptions.js";
|
|
169
169
|
export * from "./schemas/public.js";
|
|
170
170
|
export * from "./schemas/push.js";
|
|
171
|
+
export * from "./schemas/repo-checks.js";
|
|
171
172
|
export * from "./schemas/secrets.js";
|
|
172
173
|
export * from "./schemas/sessions.js";
|
|
173
174
|
export * from "./schemas/settings.js";
|
|
@@ -35,6 +35,10 @@ class FakeSocket implements SocketLike {
|
|
|
35
35
|
says(): void {
|
|
36
36
|
this.emit("message");
|
|
37
37
|
}
|
|
38
|
+
// What a socket that cannot reach its far end emits before it closes: no code, no reason, just a fault.
|
|
39
|
+
errors(): void {
|
|
40
|
+
this.emit("error");
|
|
41
|
+
}
|
|
38
42
|
private emit(type: string, code?: number): void {
|
|
39
43
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
40
44
|
listener(code === undefined ? {} : { code });
|
|
@@ -57,7 +61,7 @@ const ladder = (delay = 1_000) => {
|
|
|
57
61
|
};
|
|
58
62
|
};
|
|
59
63
|
|
|
60
|
-
const dialling = () => {
|
|
64
|
+
const dialling = (delay?: number) => {
|
|
61
65
|
const sockets: FakeSocket[] = [];
|
|
62
66
|
const attached: FakeSocket[] = [];
|
|
63
67
|
const said: string[] = [];
|
|
@@ -70,7 +74,7 @@ const dialling = () => {
|
|
|
70
74
|
},
|
|
71
75
|
hello: () => ({ type: "hello", token: "iht_test", version: "1.0.0" }),
|
|
72
76
|
attach: (socket) => void attached.push(socket),
|
|
73
|
-
backoff: ladder(),
|
|
77
|
+
backoff: ladder(delay),
|
|
74
78
|
silenceMs: SILENCE_MS,
|
|
75
79
|
log: (message) => void said.push(message),
|
|
76
80
|
revoked,
|
|
@@ -153,6 +157,73 @@ test("a socket that goes silent is abandoned and redialled, though no close ever
|
|
|
153
157
|
}
|
|
154
158
|
});
|
|
155
159
|
|
|
160
|
+
/* THE FAR END THAT IS NEVER COMING BACK, which is not a failure the loop can fix and not one it may narrate
|
|
161
|
+
* forever: a sandbox deleted, a tunnel repointed, a machine left on for a week. Every attempt here errors and
|
|
162
|
+
* closes without ever opening, exactly as a laptop's agent did 1,992 times into a 2.3 MB log. What is asserted
|
|
163
|
+
* is BOTH halves: the log stops repeating, and the dialling does not slow down to achieve it. */
|
|
164
|
+
test("a far end that never answers is reported a few times, then retried quietly at the same cadence", async () => {
|
|
165
|
+
vi.useFakeTimers();
|
|
166
|
+
try {
|
|
167
|
+
const { link, sockets, said } = dialling(60_000);
|
|
168
|
+
for (let attempt = 1; attempt <= 20; attempt += 1) {
|
|
169
|
+
// oxlint-disable-next-line eslint/no-await-in-loop -- one attempt after another is the thing under test
|
|
170
|
+
await vi.waitFor(() => expect(sockets).toHaveLength(attempt));
|
|
171
|
+
sockets.at(-1)?.errors();
|
|
172
|
+
sockets.at(-1)?.drops(1006);
|
|
173
|
+
// oxlint-disable-next-line eslint/no-await-in-loop -- the ladder's own wait, serial by construction
|
|
174
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Twenty minutes of a dead link: eight lines, the last of them a count rather than a repetition.
|
|
178
|
+
expect(said).toEqual([
|
|
179
|
+
"connection error",
|
|
180
|
+
"disconnected (1006); reconnecting in 60s",
|
|
181
|
+
"connection error",
|
|
182
|
+
"disconnected (1006); reconnecting in 60s",
|
|
183
|
+
"connection error",
|
|
184
|
+
"disconnected (1006); reconnecting in 60s",
|
|
185
|
+
expect.stringContaining("still nothing after 4 attempts"),
|
|
186
|
+
expect.stringContaining("14 failed attempts"),
|
|
187
|
+
]);
|
|
188
|
+
// The retries themselves are untouched: one dial per ladder delay, still going, plus the one now armed.
|
|
189
|
+
await vi.waitFor(() => expect(sockets).toHaveLength(21));
|
|
190
|
+
|
|
191
|
+
link.stop();
|
|
192
|
+
await link.done;
|
|
193
|
+
} finally {
|
|
194
|
+
vi.useRealTimers();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Quiet is a property of the current outage, not of the link: whatever it hid, the next one starts from nothing.
|
|
199
|
+
test("a link that comes back is loud again about the outage after it", async () => {
|
|
200
|
+
vi.useFakeTimers();
|
|
201
|
+
try {
|
|
202
|
+
const { link, sockets, said } = dialling();
|
|
203
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
204
|
+
// oxlint-disable-next-line eslint/no-await-in-loop -- one attempt after another is the thing under test
|
|
205
|
+
await vi.waitFor(() => expect(sockets).toHaveLength(attempt));
|
|
206
|
+
sockets.at(-1)?.drops(1006);
|
|
207
|
+
// oxlint-disable-next-line eslint/no-await-in-loop -- the ladder's own wait, serial by construction
|
|
208
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
209
|
+
}
|
|
210
|
+
const whileQuiet = said.length;
|
|
211
|
+
|
|
212
|
+
await vi.waitFor(() => expect(sockets).toHaveLength(6));
|
|
213
|
+
sockets.at(-1)?.opens();
|
|
214
|
+
await vi.waitFor(() => expect(said.at(-1)).toBe("connected #6"));
|
|
215
|
+
sockets.at(-1)?.drops(1006);
|
|
216
|
+
|
|
217
|
+
expect(said.at(-1)).toBe("disconnected (1006); reconnecting in 1s");
|
|
218
|
+
expect(said).toHaveLength(whileQuiet + 2);
|
|
219
|
+
|
|
220
|
+
link.stop();
|
|
221
|
+
await link.done;
|
|
222
|
+
} finally {
|
|
223
|
+
vi.useRealTimers();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
156
227
|
test("a refused enrollment (1008) is never retried and is reported once", async () => {
|
|
157
228
|
vi.useFakeTimers();
|
|
158
229
|
try {
|
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
// Reconnect backoff: fast floor for a restart, low cap so a reopened laptop is back within a minute.
|
|
6
6
|
export const PEER_LINK_BACKOFF = { floorMs: 1_000, capMs: 30_000, stableMs: 60_000 } as const;
|
|
7
7
|
|
|
8
|
+
/* HOW MUCH A LINK THAT CANNOT BE REACHED IS ALLOWED TO SAY, which is a different question from how often it
|
|
9
|
+
* may try. At the cap above, a far end that is gone for good — a sandbox deleted, a tunnel pointed elsewhere —
|
|
10
|
+
* costs two lines every 30 seconds for as long as the machine is on: 5,760 a day. One laptop's agent had
|
|
11
|
+
* written 1,992 pairs of them, 2.3 MB, and this log is exactly where its owner had been sent to read why a
|
|
12
|
+
* DIFFERENT thing had failed; the answer was in there, under an hour of repetition.
|
|
13
|
+
*
|
|
14
|
+
* The cadence is not the problem and is deliberately untouched — a reopened laptop must be back within a
|
|
15
|
+
* minute, which is what the low cap buys. The REPETITION is. So the first few failures are reported in full,
|
|
16
|
+
* then the loop says so once more to mark that it is going quiet, and after that repeats itself at most once
|
|
17
|
+
* per QUIET_LOG_MS with the attempt count that says how long it has been trying. A link that opens resets all
|
|
18
|
+
* of it: every reconnect is news, and the reconnect line is what reports it. */
|
|
19
|
+
const LOUD_ATTEMPTS = 3;
|
|
20
|
+
const QUIET_LOG_MS = 10 * 60_000;
|
|
21
|
+
|
|
8
22
|
/* HOW LONG A SOCKET MAY SAY NOTHING before this side calls the link dead, as a multiple of the door's own
|
|
9
23
|
* heartbeat: the hub pings every live peer on an interval (peer-hub.ts), so a socket with nothing on it for
|
|
10
24
|
* three heartbeats is not quiet, it is gone.
|
|
@@ -57,12 +71,16 @@ export interface PeerDialSpec<S extends SocketLike> {
|
|
|
57
71
|
readonly revoked: () => void;
|
|
58
72
|
}
|
|
59
73
|
|
|
74
|
+
// What the socket is doing right now. "connecting" covers a dial in flight and a retry waiting on the ladder:
|
|
75
|
+
// nobody should start another. Named, because processes that are not this one report it (a machine agent stamps
|
|
76
|
+
// it for `status`, which otherwise has only the link list on disk and no idea whether any of it is up).
|
|
77
|
+
export type PeerLinkState = "open" | "connecting" | "closed";
|
|
78
|
+
|
|
60
79
|
export interface PeerLink {
|
|
61
80
|
// Resolves when the loop is asked to stop or refused for good; never rejects, a connection error is a retry.
|
|
62
81
|
readonly done: Promise<void>;
|
|
63
82
|
readonly stop: (reason?: string) => void;
|
|
64
|
-
|
|
65
|
-
readonly state: () => "open" | "connecting" | "closed";
|
|
83
|
+
readonly state: () => PeerLinkState;
|
|
66
84
|
}
|
|
67
85
|
|
|
68
86
|
export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink => {
|
|
@@ -77,6 +95,32 @@ export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink
|
|
|
77
95
|
const done = new Promise<void>((resolve) => {
|
|
78
96
|
resolveDone = resolve;
|
|
79
97
|
});
|
|
98
|
+
// Consecutive attempts that have failed since this link was last open, and when the loop last complained out
|
|
99
|
+
// loud: between them they are the whole of the quiet rule above.
|
|
100
|
+
let failures = 0;
|
|
101
|
+
let quietSince = 0;
|
|
102
|
+
|
|
103
|
+
/* What ONE failed attempt is allowed to say. Three sentences rather than one repeated forever: the first few
|
|
104
|
+
* failures in full, then the line that marks the loop going quiet (so a reader who sees it knows the retries
|
|
105
|
+
* continue unlogged), then a complaint carrying the attempt count at most once per window. */
|
|
106
|
+
const complain = (said: string, delay: number): void => {
|
|
107
|
+
const every = `retrying every ${Math.round(delay / 1000)}s`;
|
|
108
|
+
if (failures <= LOUD_ATTEMPTS) {
|
|
109
|
+
spec.log(`${said}; reconnecting in ${Math.round(delay / 1000)}s`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (failures === LOUD_ATTEMPTS + 1) {
|
|
113
|
+
quietSince = Date.now();
|
|
114
|
+
spec.log(
|
|
115
|
+
`${said}; still nothing after ${failures} attempts — ${every}, and saying so at most every ${Math.round(QUIET_LOG_MS / 60_000)} minutes from here`,
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (Date.now() - quietSince >= QUIET_LOG_MS) {
|
|
120
|
+
quietSince = Date.now();
|
|
121
|
+
spec.log(`${said}; ${failures} failed attempts, ${every}`);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
80
124
|
|
|
81
125
|
const open = async (): Promise<void> => {
|
|
82
126
|
waiting = true;
|
|
@@ -123,7 +167,8 @@ export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink
|
|
|
123
167
|
}
|
|
124
168
|
const delay = spec.backoff.next(openedAt === undefined ? 0 : Date.now() - openedAt);
|
|
125
169
|
openedAt = undefined;
|
|
126
|
-
|
|
170
|
+
failures += 1;
|
|
171
|
+
complain(said, delay);
|
|
127
172
|
waiting = true;
|
|
128
173
|
setTimeout(() => void open(), delay);
|
|
129
174
|
};
|
|
@@ -149,6 +194,10 @@ export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink
|
|
|
149
194
|
return; // abandoned mid-connect: this socket is already closed and its replacement is on the ladder
|
|
150
195
|
}
|
|
151
196
|
openedAt = Date.now();
|
|
197
|
+
// A link that is up owes nothing to the failures behind it: the next outage is news again, and the
|
|
198
|
+
// "connected to …" line this open is about to log is what reports the recovery.
|
|
199
|
+
failures = 0;
|
|
200
|
+
quietSince = 0;
|
|
152
201
|
arm();
|
|
153
202
|
spec.attach(ws);
|
|
154
203
|
const send = (hello: Record<string, unknown>): void => {
|
|
@@ -184,8 +233,15 @@ export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink
|
|
|
184
233
|
drop(`disconnected (${event.code ?? "no code"})`);
|
|
185
234
|
});
|
|
186
235
|
|
|
187
|
-
|
|
188
|
-
|
|
236
|
+
/* Always followed by a close event that owns the retry; this only records a cause the close code can't
|
|
237
|
+
* carry, so it is silenced with the rest once the loop goes quiet — it is half of every repeated pair in
|
|
238
|
+
* a dead link's log, and it says nothing the drop line beside it does not. The attempt that finally
|
|
239
|
+
* reconnects is loud again, this line included. */
|
|
240
|
+
ws.addEventListener("error", () => {
|
|
241
|
+
if (failures < LOUD_ATTEMPTS) {
|
|
242
|
+
spec.log("connection error");
|
|
243
|
+
}
|
|
244
|
+
});
|
|
189
245
|
};
|
|
190
246
|
|
|
191
247
|
void open();
|
package/src/schemas/devices.ts
CHANGED
|
@@ -237,9 +237,14 @@ export const AGENT_STALL_AFTER_MS = 60_000;
|
|
|
237
237
|
export const agentStalled = (agent: DeviceAgent, now: number): boolean =>
|
|
238
238
|
agent.running && agent.lastTickAt !== undefined && now - agent.lastTickAt > AGENT_STALL_AFTER_MS;
|
|
239
239
|
export const DeviceReportSchema = z.object({
|
|
240
|
-
// OS hostname; the join key that dedupes a machine seen via sync and via its `host` capability.
|
|
240
|
+
// OS hostname; the join key that dedupes a machine seen via sync and via its `host` capability. Not unique on its
|
|
241
|
+
// own: a WSL distro inherits the Windows machine's name, so `wsl` below is what tells those apart.
|
|
241
242
|
hostname: z.string(),
|
|
242
243
|
os: z.string(),
|
|
244
|
+
// Present only inside a WSL distro. `distro` is that distro's own name ("Arch", "Ubuntu-22.04"), empty when the
|
|
245
|
+
// machine won't say. Windows, and every distro it hosts, all answer `hostname` with the same string while being
|
|
246
|
+
// separate filesystems running separate agents, so this is the only thing that keeps them apart.
|
|
247
|
+
wsl: z.object({ distro: z.string() }).optional(),
|
|
243
248
|
// Filled by the reader, never the agent; empty means no Docker or nothing looked, not that none exist.
|
|
244
249
|
sandboxes: z.array(DeviceSandboxSchema),
|
|
245
250
|
pairings: z.array(DevicePairingSchema),
|
|
@@ -256,6 +261,21 @@ export type DeviceReport = z.infer<typeof DeviceReportSchema>;
|
|
|
256
261
|
export const REPORT_QUIET_AFTER_MS = 60_000;
|
|
257
262
|
export const reportQuiet = (report: DeviceReport, receivedAt: number): boolean => receivedAt - report.capturedAt > REPORT_QUIET_AFTER_MS;
|
|
258
263
|
|
|
264
|
+
// The environment a reading came from, as opposed to the machine hosting it: a Windows install and every WSL distro
|
|
265
|
+
// on it are separate filesystems running separate agents, and all of them answer `hostname` with the same string.
|
|
266
|
+
// Undefined when nothing has reported — an absence of evidence, never read as agreement.
|
|
267
|
+
export const environmentOf = (report: DeviceReport | undefined): string | undefined =>
|
|
268
|
+
report === undefined ? undefined : report.wsl === undefined ? report.os : `wsl:${report.wsl.distro}`;
|
|
269
|
+
|
|
270
|
+
// Whether two readings positively disagree about which environment they describe. False whenever either side has not
|
|
271
|
+
// said, so this only ever blocks a fold it holds evidence against, and an agent too old to report `wsl` keeps the
|
|
272
|
+
// behaviour it had before the field existed.
|
|
273
|
+
export const differentEnvironment = (left: DeviceReport | undefined, right: DeviceReport | undefined): boolean => {
|
|
274
|
+
const a = environmentOf(left);
|
|
275
|
+
const b = environmentOf(right);
|
|
276
|
+
return a !== undefined && b !== undefined && a !== b;
|
|
277
|
+
};
|
|
278
|
+
|
|
259
279
|
// Compares running build against installed; silent when the loop is stopped, nothing installed, or installed is a dev
|
|
260
280
|
// build. An unstamped `running` still counts as skew.
|
|
261
281
|
export const agentBuildSkew = (agent: DeviceAgent): { readonly running: string | undefined; readonly installed: string } | undefined => {
|
|
@@ -278,8 +298,9 @@ export const DeviceGapSchema = z.enum([
|
|
|
278
298
|
"unreported",
|
|
279
299
|
]);
|
|
280
300
|
export type DeviceGap = z.infer<typeof DeviceGapSchema>;
|
|
281
|
-
// A machine may be reachable via desktop sync and a host capability at once; the two are reconciled on `hostname
|
|
282
|
-
// left as separate rows when there is nothing to reconcile them by
|
|
301
|
+
// A machine may be reachable via desktop sync and a host capability at once; the two are reconciled on `hostname` plus
|
|
302
|
+
// the environment it came from (`environmentOf`), and left as separate rows when there is nothing to reconcile them by
|
|
303
|
+
// or when the environments positively disagree.
|
|
283
304
|
// `machine` is the enrollment's name for the box (the ssh key's comment): what reports are filed under and what the
|
|
284
305
|
// revoke route takes. Two machines sharing a key comment share one enrollment identity.
|
|
285
306
|
export const DeviceSyncSchema = z.object({
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// repo-checks: what a REPOSITORY says should be run on its own code, declared in the repository, at
|
|
2
|
+
// `<repo>/.intentic/checks.json`.
|
|
3
|
+
//
|
|
4
|
+
// The line between this file and the sandbox's own settings is authority, not subject matter: a repository may declare
|
|
5
|
+
// WHAT to run, because the command belongs beside the scripts it names and travels with the checkout; only the sandbox
|
|
6
|
+
// owner decides what happens when it fails (whether work lands, whether a push goes), and that stays in settings.json.
|
|
7
|
+
// Nothing declared here runs until the owner adopts it for that repository (settings `adoptedChecks`), the same rule
|
|
8
|
+
// git keeps for hooks, which are never cloned.
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
// Named for the occasion as a repository would say it, not for the daemon's wire moment: `turn` is `turn.ending` and
|
|
12
|
+
// `push` is `push.starting` (rules/repo-checks.ts maps them). Two, because these are the two occasions whose command a
|
|
13
|
+
// repository actually owns; a verdict moment has nothing here to express.
|
|
14
|
+
export const RepoCheckMomentSchema = z.enum(["turn", "push"]);
|
|
15
|
+
export type RepoCheckMoment = z.infer<typeof RepoCheckMomentSchema>;
|
|
16
|
+
|
|
17
|
+
export const RepoCheckSchema = z.object({
|
|
18
|
+
when: RepoCheckMomentSchema.describe("When to run it: `turn` before the assistant finishes, `push` before code leaves the machine."),
|
|
19
|
+
run: z.string().min(1).max(500).describe("The command, run in this repository's own directory, so it reads as it would in a terminal there."),
|
|
20
|
+
label: z.string().min(1).max(80).optional().describe("What to call it on screen. Absent names it after the command."),
|
|
21
|
+
// Same ceiling as a rule's own command; past it the process group is killed and the run is a failure, never a
|
|
22
|
+
// silent pass.
|
|
23
|
+
timeoutMs: z.number().min(60_000).max(3_600_000).optional().describe("How long it may take before it is killed and counted as failed."),
|
|
24
|
+
// Repo-relative, as anybody reading this file would write them; the daemon prefixes the repo id before matching,
|
|
25
|
+
// since a rule's globs are workspace-relative.
|
|
26
|
+
paths: z
|
|
27
|
+
.array(z.string().min(1))
|
|
28
|
+
.max(20)
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Only run it when the change touches these paths, written relative to this repository. Absent runs it on every change here."),
|
|
31
|
+
});
|
|
32
|
+
export type RepoCheck = z.infer<typeof RepoCheckSchema>;
|
|
33
|
+
|
|
34
|
+
// The file itself. One key, so a second concern can be added later without breaking a file anyone has written.
|
|
35
|
+
export const RepoChecksFileSchema = z.object({ checks: z.array(RepoCheckSchema).max(10).default([]) });
|
|
36
|
+
export type RepoChecksFile = z.infer<typeof RepoChecksFileSchema>;
|
|
37
|
+
|
|
38
|
+
// One repository, as a screen reads it: what it declares, and where that stands with the owner.
|
|
39
|
+
export const RepoChecksSummarySchema = z.object({
|
|
40
|
+
repo: z.string().describe('Which repository, by its workspace id ("root" is the workspace itself).'),
|
|
41
|
+
path: z.string().describe("Where the declaration lives, relative to the workspace, whether or not the file exists yet."),
|
|
42
|
+
checks: z.array(RepoCheckSchema).describe("What it declares, in the order the file lists them."),
|
|
43
|
+
adopted: z
|
|
44
|
+
.boolean()
|
|
45
|
+
.describe("Whether these are running. False means declared and inert: nothing a repository writes runs until the owner switches it on."),
|
|
46
|
+
changed: z
|
|
47
|
+
.boolean()
|
|
48
|
+
.describe(
|
|
49
|
+
"Whether the declaration changed since it was adopted, which holds it until the owner looks again. True only for a repository that was adopted before.",
|
|
50
|
+
),
|
|
51
|
+
error: z.string().optional().describe("Why the file could not be read, when it exists but does not parse. The checks list is empty in that case."),
|
|
52
|
+
});
|
|
53
|
+
export type RepoChecksSummary = z.infer<typeof RepoChecksSummarySchema>;
|
|
54
|
+
export const RepoChecksListSchema = z.object({
|
|
55
|
+
repos: z.array(RepoChecksSummarySchema).describe("Every repository that declares checks, plus any the owner has adopted before, sorted by id."),
|
|
56
|
+
});
|
|
57
|
+
export type RepoChecksList = z.infer<typeof RepoChecksListSchema>;
|
|
58
|
+
export const RepoChecksAdoptSchema = z.object({
|
|
59
|
+
repo: z.string().min(1).describe("Which repository's declaration to switch."),
|
|
60
|
+
on: z
|
|
61
|
+
.boolean()
|
|
62
|
+
.describe("On adopts what it declares as it stands now; off stops running it. Adopting again is how a changed declaration is accepted."),
|
|
63
|
+
});
|
|
64
|
+
export type RepoChecksAdopt = z.infer<typeof RepoChecksAdoptSchema>;
|
package/src/schemas/settings.ts
CHANGED
|
@@ -369,8 +369,18 @@ export const SandboxSettingsSchema = z.object({
|
|
|
369
369
|
.describe(
|
|
370
370
|
"Whether a turn killed by the sandbox restarting is re-run once it comes back. Off to begin with, for the same reason: it would spend your allowance on work you are not watching and edit files while you are still waiting for the sandbox to return. Either way the interruption is recorded rather than silently lost.",
|
|
371
371
|
),
|
|
372
|
+
// Which repositories' own declarations (`<repo>/.intentic/checks.json`) the owner has switched on, each against the
|
|
373
|
+
// fingerprint of what was declared when they did. A declaration that has since changed no longer matches its
|
|
374
|
+
// fingerprint and is held rather than run, which is what makes adoption a decision about a command rather than a
|
|
375
|
+
// permanent permission on a folder. Keyed by repo id, so `/` in the key is ordinary.
|
|
376
|
+
adoptedChecks: z
|
|
377
|
+
.record(z.string(), z.string())
|
|
378
|
+
.default({})
|
|
379
|
+
.describe(
|
|
380
|
+
"Which repositories may run the checks they declare for themselves, and exactly which version of those checks you agreed to. A repository's declaration does nothing until it appears here, the same rule git keeps for hooks, which are never cloned; and a declaration that changes afterwards is held until you look at it again.",
|
|
381
|
+
),
|
|
372
382
|
// Lives in the owner's own settings, not the workspace: a rule can hold work and gate a push, so it answers to the
|
|
373
|
-
// sandbox owner alone.
|
|
383
|
+
// sandbox owner alone. A repository may declare a COMMAND of its own (see `adoptedChecks`), never a verdict.
|
|
374
384
|
rules: z
|
|
375
385
|
.array(RuleSchema)
|
|
376
386
|
.max(50)
|
|
@@ -511,6 +521,22 @@ export const TierReportSchema = z.object({
|
|
|
511
521
|
denied: z.number(),
|
|
512
522
|
});
|
|
513
523
|
export type TierReport = z.infer<typeof TierReportSchema>;
|
|
524
|
+
// One dependency version or library improvement suggested or pinned.
|
|
525
|
+
export const DependencyImprovementSchema = z.object({
|
|
526
|
+
prevented: z.string(),
|
|
527
|
+
chosen: z.string(),
|
|
528
|
+
reason: z.string(),
|
|
529
|
+
at: z.number().optional(),
|
|
530
|
+
});
|
|
531
|
+
export type DependencyImprovement = z.infer<typeof DependencyImprovementSchema>;
|
|
532
|
+
// Rollup of registry freshness interventions over the queried day window.
|
|
533
|
+
export const DependencySavingsSchema = z.object({
|
|
534
|
+
checked: z.number(),
|
|
535
|
+
improved: z.number(),
|
|
536
|
+
recent: z.array(DependencyImprovementSchema),
|
|
537
|
+
updatedAt: z.number().optional(),
|
|
538
|
+
});
|
|
539
|
+
export type DependencySavings = z.infer<typeof DependencySavingsSchema>;
|
|
514
540
|
export const SavingsReportSchema = z.object({
|
|
515
541
|
input: InputSavingsSchema,
|
|
516
542
|
search: TurnExperimentSchema.optional(),
|
|
@@ -518,5 +544,6 @@ export const SavingsReportSchema = z.object({
|
|
|
518
544
|
map: TurnExperimentSchema.optional(),
|
|
519
545
|
// Automatic tier selection's readout, see TierReportSchema. Absent ⇒ nothing was judged in the window.
|
|
520
546
|
tier: TierReportSchema.optional(),
|
|
547
|
+
dependencies: DependencySavingsSchema.optional(),
|
|
521
548
|
});
|
|
522
549
|
export type SavingsReport = z.infer<typeof SavingsReportSchema>;
|