instar 1.3.756 → 1.3.758
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/core/PostUpdateMigrator.d.ts +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +182 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/instarSettingsHooks.d.ts.map +1 -1
- package/dist/core/instarSettingsHooks.js +12 -0
- package/dist/core/instarSettingsHooks.js.map +1 -1
- package/dist/core/types.d.ts +16 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/scheduler/AgentMdJobLoader.d.ts +4 -0
- package/dist/scheduler/AgentMdJobLoader.d.ts.map +1 -1
- package/dist/scheduler/AgentMdJobLoader.js +2 -0
- package/dist/scheduler/AgentMdJobLoader.js.map +1 -1
- package/dist/scheduler/InstallBuiltinJobs.d.ts.map +1 -1
- package/dist/scheduler/InstallBuiltinJobs.js +3 -0
- package/dist/scheduler/InstallBuiltinJobs.js.map +1 -1
- package/dist/scheduler/JobScheduler.d.ts.map +1 -1
- package/dist/scheduler/JobScheduler.js +17 -5
- package/dist/scheduler/JobScheduler.js.map +1 -1
- package/dist/scheduler/buildPerSlugManifest.d.ts +4 -0
- package/dist/scheduler/buildPerSlugManifest.d.ts.map +1 -1
- package/dist/scheduler/buildPerSlugManifest.js +2 -0
- package/dist/scheduler/buildPerSlugManifest.js.map +1 -1
- package/package.json +1 -1
- package/scripts/doorway-scan.mjs +785 -0
- package/scripts/model-registry-freshness.manifest.json +17 -2
- package/src/data/builtin-manifest.json +20 -20
- package/src/scaffold/templates/jobs/instar/doorway-scan.md +32 -0
- package/upgrades/1.3.757.md +29 -0
- package/upgrades/1.3.758.md +37 -0
- package/upgrades/side-effects/doorway-model-registry-inc2.md +96 -0
- package/upgrades/side-effects/keyword-lint-enforce.md +113 -0
- package/dist/core/AutonomySkill.d.ts +0 -98
- package/dist/core/AutonomySkill.d.ts.map +0 -1
- package/dist/core/AutonomySkill.js +0 -497
- package/dist/core/AutonomySkill.js.map +0 -1
- package/dist/core/TopicClassifier.d.ts +0 -54
- package/dist/core/TopicClassifier.d.ts.map +0 -1
- package/dist/core/TopicClassifier.js +0 -144
- package/dist/core/TopicClassifier.js.map +0 -1
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* doorway-scan.mjs — the deterministic doorway prober (the SECURITY SPINE).
|
|
4
|
+
*
|
|
5
|
+
* Spec: docs/specs/DOORWAY-MODEL-KNOWLEDGE-REGISTRY-SPEC.md §2.0-§2.9.
|
|
6
|
+
* Rollout increment 2 (dark: the doorway-scan job ships enabled:false).
|
|
7
|
+
*
|
|
8
|
+
* This script owns 100% of the network I/O, secret handling, timeouts,
|
|
9
|
+
* size-caps, sanitization, scan-state read/write, diff/debounce/breaker, and
|
|
10
|
+
* the in-process POST /view + POST /attention delivery. The tier-1 job session
|
|
11
|
+
* only INVOKES it (a single fixed `--scope free-probes` command) and sanity-
|
|
12
|
+
* checks that it produced a well-formed result — exactly as bench-refresh wraps
|
|
13
|
+
* run2.mjs. Nothing here is entrusted to an LLM prompt (Structure > Willpower).
|
|
14
|
+
*
|
|
15
|
+
* Security posture (see spec §Security):
|
|
16
|
+
* - Secret-safe: metered doors are discovered by vault key NAMES only; a value
|
|
17
|
+
* is used in-process in a request header, NEVER in argv/environ, never echoed
|
|
18
|
+
* or written to scan-state / an attention item (§2.4).
|
|
19
|
+
* - Untrusted remote data: every remote value is clamped at the source (model
|
|
20
|
+
* ids → [A-Za-z0-9._/:-], length/count bounded; probeStatus → fixed enum; no
|
|
21
|
+
* verbatim remote/error text ever enters scan-state or a payload) (§2.0/§2.6).
|
|
22
|
+
* - Untrusted-on-every-read: `loadScanState()` re-validates every field on load
|
|
23
|
+
* (the on-disk file is machine-local plaintext — a local writer can plant
|
|
24
|
+
* well-formed poisoned values after a clamped write) (§1.3).
|
|
25
|
+
* - Money is quadruple-gated: the scheduled cadence runs a fixed
|
|
26
|
+
* `--scope free-probes`; a metered scope is refused unless the manual-marker
|
|
27
|
+
* env is set AND a positive budgetCapUsd AND a known, fresh price; and a
|
|
28
|
+
* self-standing scheduled-session refusal makes "a scheduled run cannot spend"
|
|
29
|
+
* hold even if the §2.7 guard has failed open (§2.2/§2.0/D8/D9/D10).
|
|
30
|
+
* - Never auto-applies to canonical source (§2.7 — the toolAllowlist + the
|
|
31
|
+
* PreToolUse command-allowlist guard enforce that at the session layer; this
|
|
32
|
+
* script additionally refuses to write anywhere but the scan-state file).
|
|
33
|
+
*
|
|
34
|
+
* ESM module. Pure helpers are exported for unit tests; `main()` runs only when
|
|
35
|
+
* the file is invoked directly.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { execFile as _execFile } from 'node:child_process';
|
|
41
|
+
import { promisify } from 'node:util';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
|
|
44
|
+
const execFileP = promisify(_execFile);
|
|
45
|
+
|
|
46
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** The fixed classified probeStatus enum (§1.3). Never verbatim remote text. */
|
|
49
|
+
export const PROBE_STATUS_ENUM = Object.freeze([
|
|
50
|
+
'ok',
|
|
51
|
+
'not-installed',
|
|
52
|
+
'timeout',
|
|
53
|
+
'dns-fail',
|
|
54
|
+
'http-4xx',
|
|
55
|
+
'http-5xx',
|
|
56
|
+
'malformed-response',
|
|
57
|
+
'oversize-response',
|
|
58
|
+
'not-probed-this-scope',
|
|
59
|
+
'not-probed-this-run',
|
|
60
|
+
'not-probed-budget-refused',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export const SCAN_SCOPES = Object.freeze(['free-probes', '+liveness', '+web-verify']);
|
|
64
|
+
export const SCAN_STATE_SCHEMA_VERSION = 1;
|
|
65
|
+
|
|
66
|
+
/** Live-scan-state ring-buffer + blanket array caps (§1.3). */
|
|
67
|
+
export const CHANGE_HISTORY_CAP = 20;
|
|
68
|
+
export const ARRAY_CAP = 200; // blanket per-array cap (doorways[], topModels[])
|
|
69
|
+
export const MODEL_ID_MAX = 120;
|
|
70
|
+
export const DOOR_ID_MAX = 80;
|
|
71
|
+
export const CHANGE_TEXT_MAX = 240;
|
|
72
|
+
export const MACHINE_ID_MAX = 120;
|
|
73
|
+
|
|
74
|
+
/** Per-probe + whole-scan bounds (§2.3). */
|
|
75
|
+
export const PER_PROBE_TIMEOUT_MS = 10_000;
|
|
76
|
+
export const GLOBAL_DEADLINE_MS = 5 * 60_000;
|
|
77
|
+
export const RESPONSE_SIZE_CAP_BYTES = 2 * 1024 * 1024;
|
|
78
|
+
export const COMPLETE_FAILURE_ESCALATE_THRESHOLD = 3;
|
|
79
|
+
export const PRICE_STALENESS_DAYS = 45;
|
|
80
|
+
/** Hard, price-independent structural bound on a metered liveness completion (D9). */
|
|
81
|
+
export const METERED_MAX_TOKENS_CEILING = 32;
|
|
82
|
+
|
|
83
|
+
const MODEL_ID_RE = /^[A-Za-z0-9._/:-]+$/;
|
|
84
|
+
const DOOR_ID_RE = /^[A-Za-z0-9._/:-]+$/;
|
|
85
|
+
const VAULT_KEY_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
86
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z?)?$/;
|
|
87
|
+
|
|
88
|
+
// ── Sanitization helpers (§2.0 clamp-at-source; §1.3 clamp-on-read) ───────────
|
|
89
|
+
|
|
90
|
+
/** Clamp a remote model id: charset [A-Za-z0-9._/:-], length-bounded. Non-conforming → null (dropped). */
|
|
91
|
+
export function clampModelId(raw) {
|
|
92
|
+
if (typeof raw !== 'string') return null;
|
|
93
|
+
const t = raw.trim();
|
|
94
|
+
if (!t || t.length > MODEL_ID_MAX || !MODEL_ID_RE.test(t)) return null;
|
|
95
|
+
return t;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Clamp a door id, cross-checked against the known candidate set (§1.3).
|
|
99
|
+
* Returns { id, known }: an id matching no candidate is clamped AND flagged. */
|
|
100
|
+
export function clampDoorId(raw, knownCandidates = []) {
|
|
101
|
+
if (typeof raw !== 'string') return null;
|
|
102
|
+
const t = raw.trim();
|
|
103
|
+
if (!t || t.length > DOOR_ID_MAX || !DOOR_ID_RE.test(t)) return null;
|
|
104
|
+
const known = knownCandidates.includes(t);
|
|
105
|
+
return { id: t, known };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Clamp a vault key NAME (name-only; never a value). Non-conforming → null. */
|
|
109
|
+
export function clampVaultKeyName(raw) {
|
|
110
|
+
if (typeof raw !== 'string') return null;
|
|
111
|
+
const t = raw.trim();
|
|
112
|
+
if (!t || t.length > 120 || !VAULT_KEY_NAME_RE.test(t)) return null;
|
|
113
|
+
return t;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** HTML/markdown-escape (the private-view sink is enumerated — §1.3/§2.6). */
|
|
117
|
+
export function escapeHtml(s) {
|
|
118
|
+
return String(s)
|
|
119
|
+
.replace(/&/g, '&')
|
|
120
|
+
.replace(/</g, '<')
|
|
121
|
+
.replace(/>/g, '>')
|
|
122
|
+
.replace(/"/g, '"')
|
|
123
|
+
.replace(/'/g, ''');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Strip control chars, length-clamp, THEN HTML-escape a free-text change entry (§1.3). */
|
|
127
|
+
export function sanitizeChangeText(raw) {
|
|
128
|
+
if (typeof raw !== 'string') return '';
|
|
129
|
+
// eslint-disable-next-line no-control-regex
|
|
130
|
+
const stripped = raw.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, CHANGE_TEXT_MAX);
|
|
131
|
+
return escapeHtml(stripped);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** ISO-8601 parse-or-null (dates only, or full timestamps). */
|
|
135
|
+
export function clampIso(raw) {
|
|
136
|
+
if (typeof raw !== 'string') return null;
|
|
137
|
+
const t = raw.trim();
|
|
138
|
+
if (!ISO_RE.test(t)) return null;
|
|
139
|
+
const d = new Date(t);
|
|
140
|
+
return Number.isNaN(d.getTime()) ? null : t;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function clampProbeStatus(raw) {
|
|
144
|
+
return PROBE_STATUS_ENUM.includes(raw) ? raw : 'malformed-response';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function clampScope(raw) {
|
|
148
|
+
return SCAN_SCOPES.includes(raw) ? raw : 'free-probes';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function clampMachineId(raw) {
|
|
152
|
+
if (typeof raw !== 'string') return '';
|
|
153
|
+
return raw.replace(/[\x00-\x1f\x7f]/g, '').slice(0, MACHINE_ID_MAX);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* probeStatus → reachable tri-state (§1.3 P20 mapping). `null` = UNKNOWN
|
|
158
|
+
* ("I couldn't confirm" ≠ "the door is down"); it never triggers wentUnreachable.
|
|
159
|
+
*/
|
|
160
|
+
export function probeStatusToReachable(status) {
|
|
161
|
+
switch (status) {
|
|
162
|
+
case 'ok':
|
|
163
|
+
return true;
|
|
164
|
+
// Transient / no-answer → UNKNOWN (P20 mode C) — does NOT surface wentUnreachable.
|
|
165
|
+
case 'timeout':
|
|
166
|
+
case 'dns-fail':
|
|
167
|
+
case 'http-5xx':
|
|
168
|
+
case 'not-probed-this-scope':
|
|
169
|
+
case 'not-probed-this-run':
|
|
170
|
+
case 'not-probed-budget-refused':
|
|
171
|
+
return null;
|
|
172
|
+
// Definitive unreachable — MAY surface wentUnreachable (after debounce).
|
|
173
|
+
case 'not-installed':
|
|
174
|
+
case 'http-4xx':
|
|
175
|
+
return false;
|
|
176
|
+
// Parse-drift on a door that DID answer → stays reachable:true, but surfaces
|
|
177
|
+
// the distinct L5 canary diff class (handled in computeDiff).
|
|
178
|
+
case 'malformed-response':
|
|
179
|
+
case 'oversize-response':
|
|
180
|
+
return true;
|
|
181
|
+
default:
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Is this a parse-drift (answered-but-unparseable) status? (L5 canary — §1.3.) */
|
|
187
|
+
export function isParseDrift(status) {
|
|
188
|
+
return status === 'malformed-response' || status === 'oversize-response';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── The scan-state read-validation funnel (§1.3) ──────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The EXHAUSTIVE list of scan-state fields the funnel MUST validate. A coverage
|
|
195
|
+
* test asserts loadScanState covers every entry here, so the clamp can't
|
|
196
|
+
* silently regrow holes when a field is added (§1.3 "coverage test").
|
|
197
|
+
*/
|
|
198
|
+
export const SCAN_STATE_FIELDS = Object.freeze([
|
|
199
|
+
'schemaVersion',
|
|
200
|
+
'machineId',
|
|
201
|
+
'lastScanAt',
|
|
202
|
+
'scanScope',
|
|
203
|
+
'consecutiveCompleteFailures',
|
|
204
|
+
'doorways',
|
|
205
|
+
'doorways[].id',
|
|
206
|
+
'doorways[].reachable',
|
|
207
|
+
'doorways[].probeStatus',
|
|
208
|
+
'doorways[].topModels',
|
|
209
|
+
'doorways[].lastScannedAt',
|
|
210
|
+
'doorways[].pendingFlip',
|
|
211
|
+
'doorways[].changeHistory',
|
|
212
|
+
'doorways[].changeHistory[].ts',
|
|
213
|
+
'doorways[].changeHistory[].change',
|
|
214
|
+
'lastDiff',
|
|
215
|
+
'surfacedBaseline',
|
|
216
|
+
'surfacedBaseline.doorways',
|
|
217
|
+
'surfacedBaseline.at',
|
|
218
|
+
'lastDeliveryFailedAt',
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
function clampDoorEntry(raw, knownCandidates) {
|
|
222
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
223
|
+
const idc = clampDoorId(raw.id, knownCandidates);
|
|
224
|
+
if (!idc) return null; // an id matching no known candidate / malformed is dropped
|
|
225
|
+
const probeStatus = clampProbeStatus(raw.probeStatus);
|
|
226
|
+
const reachable = typeof raw.reachable === 'boolean' ? raw.reachable : false;
|
|
227
|
+
const topModels = Array.isArray(raw.topModels)
|
|
228
|
+
? raw.topModels.slice(0, ARRAY_CAP).map(clampModelId).filter((x) => x !== null)
|
|
229
|
+
: [];
|
|
230
|
+
const changeHistory = Array.isArray(raw.changeHistory)
|
|
231
|
+
? raw.changeHistory
|
|
232
|
+
.slice(-CHANGE_HISTORY_CAP)
|
|
233
|
+
.map((h) => {
|
|
234
|
+
if (!h || typeof h !== 'object') return null;
|
|
235
|
+
const ts = clampIso(h.ts);
|
|
236
|
+
return { ts: ts ?? null, change: sanitizeChangeText(h.change) };
|
|
237
|
+
})
|
|
238
|
+
.filter((x) => x !== null)
|
|
239
|
+
: [];
|
|
240
|
+
// pendingFlip is shape-validated; a surfaced flip is ALWAYS gated on this run's
|
|
241
|
+
// live probe (§2.3/§2.6), so a planted pendingFlip alone can never surface.
|
|
242
|
+
let pendingFlip = null;
|
|
243
|
+
if (raw.pendingFlip && typeof raw.pendingFlip === 'object') {
|
|
244
|
+
const to = raw.pendingFlip.to;
|
|
245
|
+
if (to === true || to === false) {
|
|
246
|
+
pendingFlip = { to, since: clampIso(raw.pendingFlip.since) };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
id: idc.id,
|
|
251
|
+
known: idc.known,
|
|
252
|
+
reachable,
|
|
253
|
+
probeStatus,
|
|
254
|
+
topModels,
|
|
255
|
+
lastScannedAt: clampIso(raw.lastScannedAt),
|
|
256
|
+
pendingFlip,
|
|
257
|
+
changeHistory,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Load + re-validate scan-state on EVERY read (§1.3). The on-disk file is treated
|
|
263
|
+
* as untrusted data every time. A corrupt/unparseable/unreadable file → a fresh
|
|
264
|
+
* empty state (log once, never crash — matches PreferencesManager/TopicIntent).
|
|
265
|
+
* An older schemaVersion → additive field backfill (never a destructive reset).
|
|
266
|
+
*
|
|
267
|
+
* @param {string|object} src a file path OR an already-parsed object (tests).
|
|
268
|
+
* @param {{ knownCandidates?: string[], onCorrupt?: (msg:string)=>void }} [opts]
|
|
269
|
+
*/
|
|
270
|
+
export function loadScanState(src, opts = {}) {
|
|
271
|
+
const knownCandidates = opts.knownCandidates ?? [];
|
|
272
|
+
let raw;
|
|
273
|
+
if (typeof src === 'string') {
|
|
274
|
+
try {
|
|
275
|
+
raw = JSON.parse(fs.readFileSync(src, 'utf-8'));
|
|
276
|
+
} catch (err) {
|
|
277
|
+
opts.onCorrupt?.(`doorway-scan-state unreadable/corrupt — starting fresh: ${err instanceof Error ? err.message : String(err)}`);
|
|
278
|
+
return freshScanState();
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
raw = src;
|
|
282
|
+
}
|
|
283
|
+
if (!raw || typeof raw !== 'object') {
|
|
284
|
+
opts.onCorrupt?.('doorway-scan-state not an object — starting fresh');
|
|
285
|
+
return freshScanState();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const doorways = Array.isArray(raw.doorways)
|
|
289
|
+
? raw.doorways.slice(0, ARRAY_CAP).map((d) => clampDoorEntry(d, knownCandidates)).filter((x) => x !== null)
|
|
290
|
+
: [];
|
|
291
|
+
|
|
292
|
+
const sb = raw.surfacedBaseline && typeof raw.surfacedBaseline === 'object' ? raw.surfacedBaseline : {};
|
|
293
|
+
const surfacedDoorways = Array.isArray(sb.doorways)
|
|
294
|
+
? sb.doorways.slice(0, ARRAY_CAP).map((d) => clampDoorEntry(d, knownCandidates)).filter((x) => x !== null)
|
|
295
|
+
: [];
|
|
296
|
+
|
|
297
|
+
const cf = Number(raw.consecutiveCompleteFailures);
|
|
298
|
+
return {
|
|
299
|
+
schemaVersion: SCAN_STATE_SCHEMA_VERSION,
|
|
300
|
+
machineId: clampMachineId(raw.machineId),
|
|
301
|
+
lastScanAt: clampIso(raw.lastScanAt),
|
|
302
|
+
scanScope: clampScope(raw.scanScope),
|
|
303
|
+
consecutiveCompleteFailures: Number.isFinite(cf) && cf >= 0 ? Math.floor(cf) : 0,
|
|
304
|
+
doorways,
|
|
305
|
+
// lastDiff is compared-only maintainer product, recomputed fresh each run;
|
|
306
|
+
// it is never itself a sink, so it is reset (not trusted) on load.
|
|
307
|
+
lastDiff: emptyDiff(),
|
|
308
|
+
surfacedBaseline: {
|
|
309
|
+
doorways: surfacedDoorways,
|
|
310
|
+
at: clampIso(sb.at),
|
|
311
|
+
},
|
|
312
|
+
lastDeliveryFailedAt: clampIso(raw.lastDeliveryFailedAt),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function freshScanState() {
|
|
317
|
+
return {
|
|
318
|
+
schemaVersion: SCAN_STATE_SCHEMA_VERSION,
|
|
319
|
+
machineId: '',
|
|
320
|
+
lastScanAt: null,
|
|
321
|
+
scanScope: 'free-probes',
|
|
322
|
+
consecutiveCompleteFailures: 0,
|
|
323
|
+
doorways: [],
|
|
324
|
+
lastDiff: emptyDiff(),
|
|
325
|
+
surfacedBaseline: { doorways: [], at: null },
|
|
326
|
+
lastDeliveryFailedAt: null,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function emptyDiff() {
|
|
331
|
+
return {
|
|
332
|
+
newDoorways: [],
|
|
333
|
+
topModelChanged: [],
|
|
334
|
+
wentStaleOrDeprecated: [],
|
|
335
|
+
wentUnreachable: [],
|
|
336
|
+
parseDrift: [],
|
|
337
|
+
frontierSuspicions: [],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Debounce (2-scan hysteresis, ASYMMETRIC by direction — §2.3) ──────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Apply the asymmetric debounce for a single door's reachability.
|
|
345
|
+
* - Alarming transition (reachable true→false): NOT surfaced on one scan — the
|
|
346
|
+
* first divergent scan records pendingFlip; a SECOND consecutive scan agreeing
|
|
347
|
+
* flips + surfaces.
|
|
348
|
+
* - Recovery (false→true): flips IMMEDIATELY, un-debounced (a single good scan
|
|
349
|
+
* is enough to stop re-surfacing a stale degradation; a recovery raises no
|
|
350
|
+
* alarm). This asymmetry is load-bearing for the §2.6 flip-back guarantee.
|
|
351
|
+
* - Unknown (reachable:null): never a flip; clears any pendingFlip.
|
|
352
|
+
*
|
|
353
|
+
* @returns {{ effectiveReachable: boolean|null, pendingFlip: object|null, surfacedUnreachable: boolean }}
|
|
354
|
+
*/
|
|
355
|
+
export function applyDebounce(prevReachable, observedReachable, prevPendingFlip, nowIso) {
|
|
356
|
+
// Unknown this run → carry the previous known state, clear any pendingFlip, surface nothing.
|
|
357
|
+
if (observedReachable === null) {
|
|
358
|
+
return { effectiveReachable: prevReachable ?? null, pendingFlip: null, surfacedUnreachable: false };
|
|
359
|
+
}
|
|
360
|
+
// Recovery is immediate + un-debounced.
|
|
361
|
+
if (observedReachable === true) {
|
|
362
|
+
return { effectiveReachable: true, pendingFlip: null, surfacedUnreachable: false };
|
|
363
|
+
}
|
|
364
|
+
// observedReachable === false — an alarming transition. Debounce it.
|
|
365
|
+
if (prevReachable === false) {
|
|
366
|
+
// Already false (surfaced previously) — steady state, nothing new.
|
|
367
|
+
return { effectiveReachable: false, pendingFlip: null, surfacedUnreachable: false };
|
|
368
|
+
}
|
|
369
|
+
// prevReachable was true/null/unknown → this is the divergent direction.
|
|
370
|
+
const hadPendingFalse = prevPendingFlip && prevPendingFlip.to === false;
|
|
371
|
+
if (hadPendingFalse) {
|
|
372
|
+
// Second consecutive scan agreeing → flip + surface.
|
|
373
|
+
return { effectiveReachable: false, pendingFlip: null, surfacedUnreachable: true };
|
|
374
|
+
}
|
|
375
|
+
// First divergent scan → record pendingFlip, surface nothing yet.
|
|
376
|
+
return { effectiveReachable: prevReachable ?? null, pendingFlip: { to: false, since: nowIso }, surfacedUnreachable: false };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Diff (only changes; never noise; never silent pool-loss — §2.6) ───────────
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Compute the surfaced diff of OBSERVED doorways against the LAST-SURFACED
|
|
383
|
+
* baseline (NOT the always-advancing last-observed state). Only debounced,
|
|
384
|
+
* live-corroborated changes surface.
|
|
385
|
+
*
|
|
386
|
+
* @param {Array} observed this run's post-debounce door records (with probeStatus/reachable/topModels/surfacedUnreachable/known)
|
|
387
|
+
* @param {Array} lastSurfaced surfacedBaseline.doorways
|
|
388
|
+
*/
|
|
389
|
+
export function computeDiff(observed, lastSurfaced) {
|
|
390
|
+
const diff = emptyDiff();
|
|
391
|
+
const prevById = new Map((lastSurfaced ?? []).map((d) => [d.id, d]));
|
|
392
|
+
for (const d of observed ?? []) {
|
|
393
|
+
const prev = prevById.get(d.id);
|
|
394
|
+
// Parse-drift canary — surfaced whenever a door answered but no longer parses.
|
|
395
|
+
if (isParseDrift(d.probeStatus)) {
|
|
396
|
+
diff.parseDrift.push(d.id);
|
|
397
|
+
}
|
|
398
|
+
if (!prev) {
|
|
399
|
+
// A newly-configured doorway only surfaces once corroborated reachable (its
|
|
400
|
+
// own alarming appearance is debounced upstream via surfacedNew).
|
|
401
|
+
if (d.surfacedNew) diff.newDoorways.push(d.id);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
// Went definitively unreachable (only reachable:false, only after debounce flip).
|
|
405
|
+
if (d.surfacedUnreachable) diff.wentUnreachable.push(d.id);
|
|
406
|
+
// Top model changed (set inequality, order-insensitive).
|
|
407
|
+
if (!sameIdSet(prev.topModels, d.topModels)) diff.topModelChanged.push(d.id);
|
|
408
|
+
// A flaggedStale suspicion the live probe now confirms.
|
|
409
|
+
if (d.frontierSuspicionConfirmed) diff.frontierSuspicions.push(d.id);
|
|
410
|
+
// A door observed deprecated/dead.
|
|
411
|
+
if (d.wentStale) diff.wentStaleOrDeprecated.push(d.id);
|
|
412
|
+
}
|
|
413
|
+
return diff;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function sameIdSet(a, b) {
|
|
417
|
+
const sa = new Set(a ?? []);
|
|
418
|
+
const sb = new Set(b ?? []);
|
|
419
|
+
if (sa.size !== sb.size) return false;
|
|
420
|
+
for (const x of sa) if (!sb.has(x)) return false;
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function isDiffEmpty(diff) {
|
|
425
|
+
if (!diff) return true;
|
|
426
|
+
return Object.values(diff).every((arr) => Array.isArray(arr) && arr.length === 0);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Breaker + failure backoff (P19/P22 — §2.3) ────────────────────────────────
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Advance the zero-doors breaker. A completely-failed scan (zero doors answered)
|
|
433
|
+
* increments consecutiveCompleteFailures; an escalation is raised ONLY after the
|
|
434
|
+
* threshold (retry-then-escalate — P22 self-heal-before-notify). Any answered
|
|
435
|
+
* door resets the counter.
|
|
436
|
+
*
|
|
437
|
+
* @returns {{ consecutiveCompleteFailures: number, escalate: boolean }}
|
|
438
|
+
*/
|
|
439
|
+
export function advanceBreaker(prevFailures, answeredDoorCount) {
|
|
440
|
+
const prev = Number.isFinite(prevFailures) && prevFailures >= 0 ? Math.floor(prevFailures) : 0;
|
|
441
|
+
if (answeredDoorCount > 0) return { consecutiveCompleteFailures: 0, escalate: false };
|
|
442
|
+
const next = prev + 1;
|
|
443
|
+
return {
|
|
444
|
+
consecutiveCompleteFailures: next,
|
|
445
|
+
escalate: next === COMPLETE_FAILURE_ESCALATE_THRESHOLD, // exactly at threshold → escalate once
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Widening failure backoff: how many cadences to skip before the next attempt. */
|
|
450
|
+
export function backoffCadencesToSkip(failures) {
|
|
451
|
+
if (failures <= 0) return 0;
|
|
452
|
+
// 1 → 0 (retry next cadence), 2 → 1, 3 → 2, then cap at 4.
|
|
453
|
+
return Math.min(failures - 1, 4);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Money gate (§2.2/§2.0/D8/D9/D10 — the fail-closed metered predicate) ──────
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The metered-scope gate. Fail-CLOSED on every uncertainty. This is the load-
|
|
460
|
+
* bearing "scheduled runs can't spend" + "no spend without a positive, known,
|
|
461
|
+
* fresh price under a cap" predicate. It is evaluated BEFORE any completion is
|
|
462
|
+
* ever issued, so a refusal path never issues a network completion.
|
|
463
|
+
*
|
|
464
|
+
* @returns {{ permitted: boolean, reason: string }}
|
|
465
|
+
* reason ∈ 'ok' | 'free-scope' | 'scheduled-session-refused' |
|
|
466
|
+
* 'manual-marker-missing' | 'budget-absent' | 'budget-nonpositive' |
|
|
467
|
+
* 'price-unknown' | 'price-stale' | 'over-cap'
|
|
468
|
+
*/
|
|
469
|
+
export function meteredScopeGate(opts) {
|
|
470
|
+
const {
|
|
471
|
+
scope,
|
|
472
|
+
isMeteredDoor = true,
|
|
473
|
+
budgetCapUsd,
|
|
474
|
+
price, // per-probe estimated USD, or null/undefined = unknown
|
|
475
|
+
priceVerifiedAt,
|
|
476
|
+
now = new Date(),
|
|
477
|
+
manualMarker = false,
|
|
478
|
+
isScheduledSession = false,
|
|
479
|
+
} = opts;
|
|
480
|
+
|
|
481
|
+
if (scope === 'free-probes') return { permitted: false, reason: 'free-scope' };
|
|
482
|
+
if (!isMeteredDoor) return { permitted: false, reason: 'free-scope' };
|
|
483
|
+
|
|
484
|
+
// SELF-STANDING scheduled-session refusal (D10) — independent of the manual
|
|
485
|
+
// marker AND of the §2.7 guard. Makes "a scheduled run cannot spend" true even
|
|
486
|
+
// if the guard failed open.
|
|
487
|
+
if (isScheduledSession) return { permitted: false, reason: 'scheduled-session-refused' };
|
|
488
|
+
|
|
489
|
+
// Manual-marker gate (D10).
|
|
490
|
+
if (!manualMarker) return { permitted: false, reason: 'manual-marker-missing' };
|
|
491
|
+
|
|
492
|
+
// Budget gate (D9). Absent/zero/negative/non-finite → refuse every metered probe.
|
|
493
|
+
const cap = Number(budgetCapUsd);
|
|
494
|
+
if (budgetCapUsd === undefined || budgetCapUsd === null) return { permitted: false, reason: 'budget-absent' };
|
|
495
|
+
if (!Number.isFinite(cap) || cap <= 0) return { permitted: false, reason: 'budget-nonpositive' };
|
|
496
|
+
|
|
497
|
+
// Unknown/null price refuses even under a positive cap.
|
|
498
|
+
const p = Number(price);
|
|
499
|
+
if (price === undefined || price === null || !Number.isFinite(p) || p < 0) {
|
|
500
|
+
return { permitted: false, reason: 'price-unknown' };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Pricing staleness — an old verifiedAt is refused exactly like an unknown price.
|
|
504
|
+
const verified = clampIso(priceVerifiedAt);
|
|
505
|
+
if (!verified) return { permitted: false, reason: 'price-unknown' };
|
|
506
|
+
const ageDays = (now.getTime() - new Date(verified).getTime()) / 86_400_000;
|
|
507
|
+
if (ageDays > PRICE_STALENESS_DAYS) return { permitted: false, reason: 'price-stale' };
|
|
508
|
+
|
|
509
|
+
// Estimated run cost must not exceed the cap.
|
|
510
|
+
if (p > cap) return { permitted: false, reason: 'over-cap' };
|
|
511
|
+
|
|
512
|
+
return { permitted: true, reason: 'ok' };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── Attention body + private-view composition (tone-gate-safe — §2.6) ─────────
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Build the jargon-safe attention body (NO raw file paths / config keys / CLI
|
|
519
|
+
* commands — the raw maintainer-diff lives in a private view). A `/view/:id`
|
|
520
|
+
* link is included ONLY when a public tunnel URL is available; otherwise the
|
|
521
|
+
* body degrades to a link-free plain-English summary (never a localhost link,
|
|
522
|
+
* never a token-bearing URL — §2.6).
|
|
523
|
+
*
|
|
524
|
+
* @returns {{ title: string, body: string, hasLink: boolean }}
|
|
525
|
+
*/
|
|
526
|
+
export function buildAttentionBody(diff, opts = {}) {
|
|
527
|
+
const { tunnelUrl, viewId } = opts;
|
|
528
|
+
const parts = [];
|
|
529
|
+
if (diff.newDoorways.length) parts.push(`${diff.newDoorways.length} new doorway(s) noticed`);
|
|
530
|
+
if (diff.topModelChanged.length) parts.push(`${diff.topModelChanged.length} top-model change(s)`);
|
|
531
|
+
if (diff.wentUnreachable.length) parts.push(`${diff.wentUnreachable.length} doorway(s) went unreachable`);
|
|
532
|
+
if (diff.wentStaleOrDeprecated.length) parts.push(`${diff.wentStaleOrDeprecated.length} doorway(s) deprecated`);
|
|
533
|
+
if (diff.frontierSuspicions.length) parts.push(`${diff.frontierSuspicions.length} routing pin(s) may be behind`);
|
|
534
|
+
if (diff.parseDrift.length) parts.push(`${diff.parseDrift.length} doorway(s) answered but the model list no longer parses (a probe needs attention)`);
|
|
535
|
+
const count = parts.length;
|
|
536
|
+
const summary = parts.join('; ');
|
|
537
|
+
let body = `Your doorway map changed: ${summary}.`;
|
|
538
|
+
let hasLink = false;
|
|
539
|
+
// A link is only ever a bare token-free /view/:id on a PUBLIC tunnel URL.
|
|
540
|
+
if (tunnelUrl && viewId && /^https:\/\//i.test(tunnelUrl) && !/localhost|127\.0\.0\.1/i.test(tunnelUrl)) {
|
|
541
|
+
body += ` Details: ${tunnelUrl.replace(/\/+$/, '')}/view/${viewId}`;
|
|
542
|
+
hasLink = true;
|
|
543
|
+
} else {
|
|
544
|
+
body += ` Details are in the doorway scan for this machine.`;
|
|
545
|
+
}
|
|
546
|
+
return { title: `Doorway map changed: ${count} change kind(s)`, body, hasLink };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Build the raw maintainer private-view markdown (HTML-escaped free text — §1.3/§2.6). */
|
|
550
|
+
export function buildPrivateViewMarkdown(diff, machineId) {
|
|
551
|
+
const lines = [`# Doorway map changes — ${escapeHtml(machineId || 'this machine')}`, ''];
|
|
552
|
+
const emit = (label, ids) => {
|
|
553
|
+
if (!ids.length) return;
|
|
554
|
+
lines.push(`## ${label}`);
|
|
555
|
+
for (const id of ids) lines.push(`- \`${escapeHtml(id)}\``);
|
|
556
|
+
lines.push('');
|
|
557
|
+
};
|
|
558
|
+
emit('Newly-configured doorways', diff.newDoorways);
|
|
559
|
+
emit('Top-model changed (fold into topModels via instar-dev, then re-run the freshness lint)', diff.topModelChanged);
|
|
560
|
+
emit('Went definitively unreachable', diff.wentUnreachable);
|
|
561
|
+
emit('Deprecated / dead', diff.wentStaleOrDeprecated);
|
|
562
|
+
emit('Routing pin may be behind (flaggedStale confirmed live)', diff.frontierSuspicions);
|
|
563
|
+
emit('Answered but model list no longer parses — probe parser needs attention', diff.parseDrift);
|
|
564
|
+
return lines.join('\n');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── Baseline advancement (§2.6 delivery robustness) ───────────────────────────
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Decide how to advance the surfaced baseline. TWO advancement paths (§2.6):
|
|
571
|
+
* (1) INITIAL SEED — when surfacedBaseline.at === null, seed it to observed,
|
|
572
|
+
* surface NOTHING, require no delivery (no fresh-state flood).
|
|
573
|
+
* (2) subsequent non-empty diffs — advance ONLY on a confirmed 2xx delivery.
|
|
574
|
+
*
|
|
575
|
+
* @returns {{ action: 'seed'|'surface'|'noop', shouldDeliver: boolean }}
|
|
576
|
+
*/
|
|
577
|
+
export function planBaselineAdvance(surfacedAt, diff) {
|
|
578
|
+
if (surfacedAt === null || surfacedAt === undefined) {
|
|
579
|
+
return { action: 'seed', shouldDeliver: false };
|
|
580
|
+
}
|
|
581
|
+
if (isDiffEmpty(diff)) return { action: 'noop', shouldDeliver: false };
|
|
582
|
+
return { action: 'surface', shouldDeliver: true };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Candidate doorway resolution (§2.5) ───────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
/** The doors to probe FOR: candidateDoorways[] if present, else the known door ids. */
|
|
588
|
+
export function resolveCandidateDoors(manifest) {
|
|
589
|
+
if (manifest && Array.isArray(manifest.candidateDoorways) && manifest.candidateDoorways.length) {
|
|
590
|
+
return manifest.candidateDoorways.filter((x) => typeof x === 'string');
|
|
591
|
+
}
|
|
592
|
+
return manifest && manifest.doors ? Object.keys(manifest.doors) : [];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Vault-key auto-notice pattern match (§2.5) — a key name that maps to no known door. */
|
|
596
|
+
export function isMeteredDoorKeyPattern(name) {
|
|
597
|
+
return /^metered_/.test(name) || /_api_key$/.test(name) || /_bench$/.test(name);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── Network probes (deterministic; injectable deps for tests) ─────────────────
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Probe a CLI door: `which <bin>` then `<bin> --version` / a model-list subcommand.
|
|
604
|
+
* Every remote value is clamped; the classified probeStatus never carries verbatim text.
|
|
605
|
+
* Deps (`exec`) are injectable so tests never spawn real processes.
|
|
606
|
+
*/
|
|
607
|
+
export async function probeCliDoor(door, deps = {}) {
|
|
608
|
+
const exec = deps.exec ?? ((file, args) => execFileP(file, args, { timeout: PER_PROBE_TIMEOUT_MS, maxBuffer: RESPONSE_SIZE_CAP_BYTES }));
|
|
609
|
+
const bin = door?.probe?.bin || door?.id;
|
|
610
|
+
if (!bin || typeof bin !== 'string' || !/^[A-Za-z0-9._-]+$/.test(bin)) {
|
|
611
|
+
return { probeStatus: 'not-installed', topModels: [] };
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
await exec('which', [bin]);
|
|
615
|
+
} catch {
|
|
616
|
+
return { probeStatus: 'not-installed', topModels: [] };
|
|
617
|
+
}
|
|
618
|
+
// Model-list subcommand where one exists; otherwise --version confirms liveness only.
|
|
619
|
+
const listArgs = Array.isArray(door?.probe?.listArgs) ? door.probe.listArgs : null;
|
|
620
|
+
try {
|
|
621
|
+
if (listArgs) {
|
|
622
|
+
const { stdout } = await exec(bin, listArgs);
|
|
623
|
+
const ids = parseModelIds(stdout);
|
|
624
|
+
return { probeStatus: 'ok', topModels: ids };
|
|
625
|
+
}
|
|
626
|
+
await exec(bin, ['--version']);
|
|
627
|
+
return { probeStatus: 'ok', topModels: [] };
|
|
628
|
+
} catch (err) {
|
|
629
|
+
if (err && err.killed) return { probeStatus: 'timeout', topModels: [] };
|
|
630
|
+
return { probeStatus: 'malformed-response', topModels: [] };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Probe an API door's model-LIST endpoint IN-PROCESS (fetch with the auth header
|
|
636
|
+
* set on the request object — a secret is NEVER placed in argv/environ). A
|
|
637
|
+
* model-list spends ZERO tokens, so a raw in-process GET is correct (§2.0).
|
|
638
|
+
* Response body is size-capped BEFORE parsing.
|
|
639
|
+
*/
|
|
640
|
+
export async function probeHttpModelListDoor(door, deps = {}) {
|
|
641
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
642
|
+
const url = door?.probe?.listUrl;
|
|
643
|
+
if (!url || typeof url !== 'string') return { probeStatus: 'not-probed-this-scope', topModels: [] };
|
|
644
|
+
const headers = {};
|
|
645
|
+
// Secret used in-process in the header only — the caller resolves the value and
|
|
646
|
+
// passes it here; it is never logged/written/echoed (§2.4).
|
|
647
|
+
if (deps.authHeaderValue) headers['Authorization'] = deps.authHeaderValue;
|
|
648
|
+
const controller = new AbortController();
|
|
649
|
+
const timer = setTimeout(() => controller.abort(), PER_PROBE_TIMEOUT_MS);
|
|
650
|
+
try {
|
|
651
|
+
const resp = await fetchImpl(url, { headers, signal: controller.signal });
|
|
652
|
+
if (resp.status >= 400 && resp.status < 500) return { probeStatus: 'http-4xx', topModels: [] };
|
|
653
|
+
if (resp.status >= 500) return { probeStatus: 'http-5xx', topModels: [] };
|
|
654
|
+
const text = await readCapped(resp);
|
|
655
|
+
if (text === null) return { probeStatus: 'oversize-response', topModels: [] };
|
|
656
|
+
let parsed;
|
|
657
|
+
try {
|
|
658
|
+
parsed = JSON.parse(text);
|
|
659
|
+
} catch {
|
|
660
|
+
return { probeStatus: 'malformed-response', topModels: [] };
|
|
661
|
+
}
|
|
662
|
+
const ids = parseModelIds(parsed);
|
|
663
|
+
if (!ids.length) return { probeStatus: 'malformed-response', topModels: [] };
|
|
664
|
+
return { probeStatus: 'ok', topModels: ids };
|
|
665
|
+
} catch (err) {
|
|
666
|
+
if (err && (err.name === 'AbortError' || err.name === 'TimeoutError')) return { probeStatus: 'timeout', topModels: [] };
|
|
667
|
+
return { probeStatus: 'dns-fail', topModels: [] };
|
|
668
|
+
} finally {
|
|
669
|
+
clearTimeout(timer);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Read a fetch Response body up to the size cap; null if it exceeds the cap. */
|
|
674
|
+
async function readCapped(resp) {
|
|
675
|
+
// Prefer a streamed read so we never buffer an oversize body.
|
|
676
|
+
try {
|
|
677
|
+
if (resp.body && typeof resp.body.getReader === 'function') {
|
|
678
|
+
const reader = resp.body.getReader();
|
|
679
|
+
let total = 0;
|
|
680
|
+
const chunks = [];
|
|
681
|
+
for (;;) {
|
|
682
|
+
const { done, value } = await reader.read();
|
|
683
|
+
if (done) break;
|
|
684
|
+
total += value.length;
|
|
685
|
+
if (total > RESPONSE_SIZE_CAP_BYTES) {
|
|
686
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
chunks.push(value);
|
|
690
|
+
}
|
|
691
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf-8');
|
|
692
|
+
}
|
|
693
|
+
} catch { /* fall through to text() */ }
|
|
694
|
+
const t = await resp.text();
|
|
695
|
+
if (Buffer.byteLength(t, 'utf-8') > RESPONSE_SIZE_CAP_BYTES) return null;
|
|
696
|
+
return t;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Extract + clamp model ids from a CLI stdout string or a parsed API list body. */
|
|
700
|
+
export function parseModelIds(input) {
|
|
701
|
+
const out = [];
|
|
702
|
+
const push = (v) => {
|
|
703
|
+
const id = clampModelId(v);
|
|
704
|
+
if (id && !out.includes(id)) out.push(id);
|
|
705
|
+
};
|
|
706
|
+
if (typeof input === 'string') {
|
|
707
|
+
for (const tok of input.split(/[\s,]+/)) push(tok);
|
|
708
|
+
} else if (input && typeof input === 'object') {
|
|
709
|
+
const list = Array.isArray(input) ? input : Array.isArray(input.data) ? input.data : Array.isArray(input.models) ? input.models : [];
|
|
710
|
+
for (const m of list) {
|
|
711
|
+
if (typeof m === 'string') push(m);
|
|
712
|
+
else if (m && typeof m === 'object') push(m.id ?? m.name ?? m.model);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return out.slice(0, ARRAY_CAP);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── Orchestrator (composes the pure pieces; deps injectable for tests) ────────
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Detect whether this invocation is inside a SCHEDULED doorway-scan job session
|
|
722
|
+
* (the self-standing spend refusal — §2.0/D10). Independent of the manual marker.
|
|
723
|
+
*/
|
|
724
|
+
export function isScheduledDoorwayScanSession(env = process.env, stateDir = '.instar') {
|
|
725
|
+
if (env.INSTAR_JOB_SLUG === 'doorway-scan') return true;
|
|
726
|
+
try {
|
|
727
|
+
const aj = JSON.parse(fs.readFileSync(path.join(stateDir, 'state', 'active-job.json'), 'utf-8'));
|
|
728
|
+
if (aj && aj.slug === 'doorway-scan') return true;
|
|
729
|
+
} catch { /* absent → not a scheduled session by this signal */ }
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ── main() — only when invoked directly ───────────────────────────────────────
|
|
734
|
+
|
|
735
|
+
function parseArgs(argv) {
|
|
736
|
+
const out = { scope: 'free-probes' };
|
|
737
|
+
for (let i = 0; i < argv.length; i++) {
|
|
738
|
+
if (argv[i] === '--scope') out.scope = clampScope(argv[i + 1]);
|
|
739
|
+
}
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function main() {
|
|
744
|
+
const args = parseArgs(process.argv.slice(2));
|
|
745
|
+
// Belt-and-suspenders write-restraint (§2.0): this script only ever writes the
|
|
746
|
+
// scan-state file (+ a temp sibling). It has no --out and refuses any other target.
|
|
747
|
+
const stateDir = process.env.INSTAR_STATE_DIR || '.instar';
|
|
748
|
+
const scanStatePath = path.join(stateDir, 'state', 'doorway-scan.json');
|
|
749
|
+
const manualMarker = process.env.INSTAR_DOORWAY_SCAN_MANUAL === '1';
|
|
750
|
+
const scheduled = isScheduledDoorwayScanSession(process.env, stateDir);
|
|
751
|
+
|
|
752
|
+
// Dark posture: with no manifest present this agent has no registry to scan.
|
|
753
|
+
const manifestPath = path.join('scripts', 'model-registry-freshness.manifest.json');
|
|
754
|
+
if (!fs.existsSync(manifestPath)) {
|
|
755
|
+
console.log('doorway-scan: no canonical manifest present — nothing to scan (silent no-op).');
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// A metered scope from a scheduled session is refused up-front (self-standing).
|
|
760
|
+
if (args.scope !== 'free-probes' && (scheduled || !manualMarker)) {
|
|
761
|
+
console.log(`doorway-scan: metered scope "${args.scope}" refused (scheduled-session or manual-marker-missing) — running free-probes.`);
|
|
762
|
+
args.scope = 'free-probes';
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
console.log(`doorway-scan: scope=${args.scope} scheduled=${scheduled} stateFile=${scanStatePath}`);
|
|
766
|
+
console.log('doorway-scan: increment 2 ships DARK — the job is enabled:false; this is the deterministic prober module.');
|
|
767
|
+
// The full cadence orchestration (probe → debounce → diff → deliver) is exercised
|
|
768
|
+
// via the exported pure functions and the enabled job (rollout step 4). Running
|
|
769
|
+
// main() by hand here confirms wiring + writes/refreshes scan-state harmlessly.
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const isDirect = (() => {
|
|
773
|
+
try {
|
|
774
|
+
return process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
775
|
+
} catch {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
})();
|
|
779
|
+
|
|
780
|
+
if (isDirect) {
|
|
781
|
+
main().catch((err) => {
|
|
782
|
+
console.error(`doorway-scan: fatal (non-gating): ${err instanceof Error ? err.message : String(err)}`);
|
|
783
|
+
process.exit(0); // never a hard failure — next cadence retries (fail-safe)
|
|
784
|
+
});
|
|
785
|
+
}
|