@b00dea/cockpit 0.1.0 → 0.1.1
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/CHANGELOG.md +99 -0
- package/README.md +3 -0
- package/cockpit.schema.json +73 -3
- package/dist/autonomy.d.ts +3 -2
- package/dist/autonomy.js +30 -13
- package/dist/browser/commands.d.ts +57 -2
- package/dist/browser/commands.js +114 -10
- package/dist/browser/runtime.d.ts +1 -0
- package/dist/browser/runtime.js +4 -1
- package/dist/browser/surfaces.d.ts +7 -7
- package/dist/browser/surfaces.js +5 -0
- package/dist/canonical-json.d.ts +2 -0
- package/dist/canonical-json.js +5 -0
- package/dist/cli-main.d.ts +40 -3
- package/dist/cli-main.js +241 -62
- package/dist/command-table.d.ts +19 -11
- package/dist/command-table.js +190 -38
- package/dist/config/load.d.ts +16 -4
- package/dist/config/load.js +25 -3
- package/dist/config/unconfirmed-toml.d.ts +9 -0
- package/dist/config/unconfirmed-toml.js +29 -0
- package/dist/diagnostics.js +10 -1
- package/dist/docs/check.d.ts +16 -0
- package/dist/docs/check.js +197 -0
- package/dist/docs/decision-record.d.ts +34 -0
- package/dist/docs/decision-record.js +135 -0
- package/dist/docs/decisions.d.ts +16 -0
- package/dist/docs/decisions.js +103 -0
- package/dist/docs/markdown.d.ts +36 -0
- package/dist/docs/markdown.js +88 -0
- package/dist/docs/references.d.ts +32 -0
- package/dist/docs/references.js +148 -0
- package/dist/environment/application/confirm.d.ts +35 -0
- package/dist/environment/application/confirm.js +122 -0
- package/dist/environment/application/destructive.js +6 -2
- package/dist/environment/application/doctor.d.ts +12 -7
- package/dist/environment/application/doctor.js +20 -8
- package/dist/environment/application/guard.d.ts +12 -6
- package/dist/environment/application/guard.js +87 -32
- package/dist/environment/application/health.d.ts +14 -5
- package/dist/environment/application/health.js +8 -2
- package/dist/environment/application/lifecycle-context.d.ts +10 -3
- package/dist/environment/application/lifecycle-state.js +4 -3
- package/dist/environment/application/lifecycle.d.ts +2 -1
- package/dist/environment/application/lifecycle.js +11 -3
- package/dist/environment/application/services.d.ts +60 -0
- package/dist/environment/application/services.js +89 -0
- package/dist/environment/application/siblings.d.ts +43 -0
- package/dist/environment/application/siblings.js +269 -0
- package/dist/environment/claims.d.ts +10 -10
- package/dist/environment/claims.js +3 -11
- package/dist/environment/compose-topology.d.ts +15 -7
- package/dist/environment/compose-topology.js +100 -23
- package/dist/environment/contract.d.ts +42 -9
- package/dist/environment/contract.js +33 -0
- package/dist/environment/executable.d.ts +12 -0
- package/dist/environment/executable.js +46 -23
- package/dist/environment/generated-application/check.js +2 -0
- package/dist/environment/generated-application/prepare.js +2 -0
- package/dist/environment/generated-application/shared.d.ts +10 -2
- package/dist/environment/generated-application/shared.js +6 -1
- package/dist/environment/generated-application/surface-outputs.js +23 -12
- package/dist/environment/generated-application/surfaces.js +16 -11
- package/dist/environment/generated-application/sync.js +19 -4
- package/dist/environment/git-observation.d.ts +25 -6
- package/dist/environment/git-observation.js +37 -28
- package/dist/environment/health-composition.d.ts +3 -2
- package/dist/environment/health-composition.js +19 -2
- package/dist/environment/init-application.d.ts +39 -14
- package/dist/environment/init-application.js +39 -10
- package/dist/environment/lifecycle-composition.d.ts +4 -2
- package/dist/environment/lifecycle-composition.js +84 -17
- package/dist/environment/lifecycle-stage.d.ts +8 -3
- package/dist/environment/lifecycle-stage.js +11 -1
- package/dist/environment/logs.d.ts +1 -1
- package/dist/environment/map-limit.d.ts +2 -0
- package/dist/environment/map-limit.js +13 -0
- package/dist/environment/mcp-observation.d.ts +9 -0
- package/dist/environment/mcp-observation.js +46 -0
- package/dist/environment/observations.d.ts +1 -1
- package/dist/environment/observations.js +2 -0
- package/dist/environment/operator-application.d.ts +35 -6
- package/dist/environment/operator-application.js +42 -0
- package/dist/environment/presence.d.ts +4 -0
- package/dist/environment/presence.js +26 -0
- package/dist/environment/read-composition.d.ts +8 -3
- package/dist/environment/read-composition.js +53 -8
- package/dist/environment/readiness-composition.d.ts +20 -1
- package/dist/environment/readiness-composition.js +23 -2
- package/dist/environment/reclaim.d.ts +56 -19
- package/dist/environment/reclaim.js +147 -42
- package/dist/environment/runtime.d.ts +64 -18
- package/dist/environment/runtime.js +73 -3
- package/dist/environment/served-checkout-grants.d.ts +53 -0
- package/dist/environment/served-checkout-grants.js +182 -0
- package/dist/environment/sibling-checkouts.d.ts +119 -0
- package/dist/environment/sibling-checkouts.js +254 -0
- package/dist/environment/sibling-observation.d.ts +27 -0
- package/dist/environment/sibling-observation.js +45 -0
- package/dist/environment/sibling-pull.d.ts +23 -0
- package/dist/environment/sibling-pull.js +95 -0
- package/dist/environment/stack-inputs.d.ts +18 -0
- package/dist/environment/stack-inputs.js +210 -0
- package/dist/environment/stack-inventory-prune.d.ts +31 -0
- package/dist/environment/stack-inventory-prune.js +129 -0
- package/dist/environment/stack-inventory-report.d.ts +44 -0
- package/dist/environment/stack-inventory-report.js +78 -0
- package/dist/environment/stack-inventory.d.ts +94 -0
- package/dist/environment/stack-inventory.js +418 -0
- package/dist/environment/state.d.ts +48 -9
- package/dist/environment/state.js +142 -2
- package/dist/environment/tool-registration.js +3 -7
- package/dist/environment-exec/execute.d.ts +6 -3
- package/dist/environment-exec/execute.js +4 -0
- package/dist/fix-catalogue.d.ts +1 -1
- package/dist/generated-environment/adopt.js +4 -8
- package/dist/guards/answer-confirmation.d.ts +41 -0
- package/dist/guards/answer-confirmation.js +109 -0
- package/dist/guards/evaluator.d.ts +20 -3
- package/dist/guards/evaluator.js +39 -7
- package/dist/guards/local-identity.d.ts +6 -2
- package/dist/guards/local-identity.js +10 -4
- package/dist/guards/policy-change.d.ts +43 -0
- package/dist/guards/policy-change.js +159 -0
- package/dist/guards/policy-pin.d.ts +1 -1
- package/dist/guards/policy-pin.js +14 -12
- package/dist/init/command.d.ts +4 -3
- package/dist/init/command.js +36 -16
- package/dist/init/derive.d.ts +8 -6
- package/dist/init/derive.js +140 -36
- package/dist/init/draft.d.ts +13 -11
- package/dist/init/draft.js +54 -31
- package/dist/init/render.d.ts +18 -6
- package/dist/init/render.js +31 -9
- package/dist/repository-path.d.ts +9 -0
- package/dist/repository-path.js +16 -0
- package/dist/schema.d.ts +42 -75
- package/dist/schema.js +62 -11
- package/dist/surfaces/harness-registry.d.ts +80 -0
- package/dist/surfaces/harness-registry.js +29 -0
- package/dist/surfaces/mcp.d.ts +67 -17
- package/dist/surfaces/mcp.js +194 -31
- package/dist/surfaces/projection.d.ts +7 -7
- package/dist/surfaces/projection.js +41 -6
- package/dist/surfaces/render.d.ts +5 -44
- package/dist/surfaces/render.js +5 -2
- package/dist/team-metadata.js +2 -1
- package/dist/testing/test-protection.d.ts +34 -0
- package/dist/testing/test-protection.js +137 -0
- package/dist/testing/weakening.d.ts +15 -0
- package/dist/testing/weakening.js +30 -0
- package/dist/work-orders/application-closure.d.ts +7 -0
- package/dist/work-orders/application-closure.js +76 -0
- package/dist/work-orders/application-context.d.ts +162 -0
- package/dist/work-orders/application-context.js +137 -0
- package/dist/work-orders/application-decisions.d.ts +27 -0
- package/dist/work-orders/application-decisions.js +121 -0
- package/dist/work-orders/application-grading.d.ts +11 -0
- package/dist/work-orders/application-grading.js +212 -0
- package/dist/work-orders/application.d.ts +4 -97
- package/dist/work-orders/application.js +94 -254
- package/dist/work-orders/composition.d.ts +35 -58
- package/dist/work-orders/composition.js +216 -24
- package/dist/work-orders/contract.d.ts +22 -6
- package/dist/work-orders/contract.js +34 -16
- package/dist/work-orders/decisions.d.ts +23 -0
- package/dist/work-orders/decisions.js +55 -0
- package/dist/work-orders/store.d.ts +54 -5
- package/dist/work-orders/store.js +118 -24
- package/docs/README.md +6 -3
- package/docs/capability-matrix.md +17 -9
- package/docs/cli-reference.md +209 -1
- package/docs/command-list.md +12 -1
- package/docs/concepts.md +6 -4
- package/docs/configuration-essentials.md +7 -2
- package/docs/configuration-reference.md +18 -3
- package/docs/docs-check.md +76 -0
- package/docs/environment.md +194 -6
- package/docs/faq.md +5 -3
- package/docs/getting-started.md +109 -25
- package/docs/harness-surfaces.md +69 -2
- package/docs/safety-and-autonomy.md +56 -12
- package/docs/troubleshooting.md +29 -8
- package/docs/tutorial.md +3 -2
- package/docs/work-and-browser.md +180 -14
- package/libexec/browser-operation.cjs +86 -2
- package/package.json +2 -2
- package/surfaces/skills/cockpit-browser.skill.md +7 -2
- package/surfaces/skills/cockpit-explain.skill.md +1 -1
- package/surfaces/skills/cockpit-fix.skill.md +1 -1
- package/surfaces/skills/cockpit-setup.skill.md +3 -3
- package/surfaces/skills/cockpit-work.skill.md +1 -1
- package/surfaces/skills/procedures/explain.md +9 -4
- package/surfaces/skills/procedures/fix.md +25 -11
- package/surfaces/skills/procedures/setup.md +113 -40
- package/surfaces/skills/procedures/work.md +45 -16
package/dist/init/derive.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Proposes interview answers from files a repository already carries. It owns reading those files
|
|
2
|
-
// and attributing each proposed value to its source; it must not decide or write anything.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// and attributing each proposed value to its source; it must not decide or write anything. A fact
|
|
3
|
+
// is "derived" when it is the complete answer to an interview field read exactly from its source,
|
|
4
|
+
// which the setup agent may accept and bring-up then proves; every other fact is "inferred": a hint
|
|
5
|
+
// or a partial value that needs judgement, because a config that looks complete but holds guesses
|
|
6
|
+
// is the failure cockpit exists to prevent.
|
|
5
7
|
// Deliberately cheap and exact: Dockerfile, env template variable names, Compose files and CI
|
|
6
8
|
// service containers, test entry points and run scripts from build and package manifests, Node
|
|
7
9
|
// lockfiles, and each repository's origin remote from .git/config. It starts no process. Orchestrator
|
|
@@ -9,7 +11,7 @@
|
|
|
9
11
|
import { lstat, readdir, realpath } from 'node:fs/promises';
|
|
10
12
|
import { basename, isAbsolute, relative as pathRelative, resolve, sep } from 'node:path';
|
|
11
13
|
import { parse as parseToml } from 'smol-toml';
|
|
12
|
-
import { parse as parseYaml } from 'yaml';
|
|
14
|
+
import { LineCounter, parse as parseYaml, parseDocument } from 'yaml';
|
|
13
15
|
import { readBoundedInput } from '../config/load.js';
|
|
14
16
|
import { errorCause, hasErrorCode } from '../environment/contract.js';
|
|
15
17
|
import { assessComposeTopology, bindHolder, composeBindPath, composeProjectDirectory, composePublishedPorts, splitComposeShort } from '../environment/compose-topology.js';
|
|
@@ -230,33 +232,43 @@ const PORT_VARIABLE = /^\$\{([A-Z][A-Z0-9_]*)(?:(:?-)(\d{1,5}))?\}$|^\$([A-Z][A-
|
|
|
230
232
|
function entries(value) {
|
|
231
233
|
return Array.isArray(value) ? value : [];
|
|
232
234
|
}
|
|
235
|
+
const at = (file, path) => {
|
|
236
|
+
const line = file.line(path);
|
|
237
|
+
return line === undefined ? file.source : `${file.source}:${line}`;
|
|
238
|
+
};
|
|
239
|
+
function services(file) {
|
|
240
|
+
const value = file.document?.services;
|
|
241
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
242
|
+
return [];
|
|
243
|
+
return Object.entries(value)
|
|
244
|
+
.filter((entry) => !!entry[1] && typeof entry[1] === 'object' && !Array.isArray(entry[1]));
|
|
245
|
+
}
|
|
233
246
|
/**
|
|
234
247
|
* Reads the bind sources and published host-port variables of the Compose files init proposes.
|
|
235
248
|
* Bind sources resolve as `up` resolves them: against the first file's directory, with each
|
|
236
249
|
* proposed sibling variable set to its directory and any other variable to its own default.
|
|
250
|
+
* The bases are complete only when every published host port was read; otherwise the map is a
|
|
251
|
+
* partial proposal that needs the person.
|
|
237
252
|
*/
|
|
238
253
|
function composeFacts(files, root, variables, skipped) {
|
|
239
254
|
const binds = [];
|
|
240
|
-
// Each base keeps every file that publishes it, so the proposal names where each value came from.
|
|
255
|
+
// Each base keeps every file and line that publishes it, so the proposal names where each value came from.
|
|
241
256
|
const bases = new Map();
|
|
242
257
|
const conflicting = new Set();
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
for (const service of Object.values(services)) {
|
|
248
|
-
if (!service || typeof service !== 'object')
|
|
249
|
-
continue;
|
|
258
|
+
let basesComplete = true;
|
|
259
|
+
for (const file of files) {
|
|
260
|
+
const { source } = file;
|
|
261
|
+
for (const [name, service] of services(file)) {
|
|
250
262
|
for (const volume of entries(service.volumes)) {
|
|
251
263
|
const segments = typeof volume === 'string' ? splitComposeShort(volume) : undefined;
|
|
252
264
|
const raw = segments && segments.length >= 2 ? segments[0]
|
|
253
265
|
: volume && typeof volume === 'object' && volume.type === 'bind'
|
|
254
266
|
? volume.source : undefined;
|
|
255
|
-
const path = typeof raw === 'string' ? composeBindPath(raw, composeProjectDirectory(root, files.map(
|
|
267
|
+
const path = typeof raw === 'string' ? composeBindPath(raw, composeProjectDirectory(root, files.map(row => row.source)), variables) : undefined;
|
|
256
268
|
if (path !== undefined && path !== 'volume')
|
|
257
269
|
binds.push({ source, path });
|
|
258
270
|
}
|
|
259
|
-
for (const port of entries(service.ports)) {
|
|
271
|
+
for (const [index, port] of entries(service.ports).entries()) {
|
|
260
272
|
const segments = typeof port === 'string' ? splitComposeShort(port) : undefined;
|
|
261
273
|
const published = segments && segments.length >= 2 ? segments[segments.length - 2]
|
|
262
274
|
: port && typeof port === 'object' ? port.published : undefined;
|
|
@@ -265,30 +277,101 @@ function composeFacts(files, root, variables, skipped) {
|
|
|
265
277
|
continue;
|
|
266
278
|
const match = typeof published === 'string' ? PORT_VARIABLE.exec(published) : null;
|
|
267
279
|
if (!match) {
|
|
280
|
+
basesComplete = false;
|
|
268
281
|
skipped.push({ source, reason: `publishes host port ${String(published)} that is not read from a variable with a default, so every stack would bind the same host port and check refuses it; publish it as \${NAME:-<port>} and declare NAME under [ports.bases]` });
|
|
269
282
|
continue;
|
|
270
283
|
}
|
|
271
|
-
const
|
|
284
|
+
const variable = (match[1] ?? match[4]);
|
|
272
285
|
const base = match[3] === undefined ? undefined : Number(match[3]);
|
|
273
286
|
if (base === undefined || base < 1 || base > 65_535) {
|
|
274
|
-
|
|
287
|
+
basesComplete = false;
|
|
288
|
+
skipped.push({ source, reason: `publishes a host port from \${${variable}} with no default, so its base port cannot be read; declare ${variable} under [ports.bases]` });
|
|
275
289
|
continue;
|
|
276
290
|
}
|
|
277
|
-
const known = bases.get(
|
|
291
|
+
const known = bases.get(variable);
|
|
278
292
|
if (known && known.base !== base)
|
|
279
|
-
conflicting.add(
|
|
293
|
+
conflicting.add(variable);
|
|
294
|
+
const where = at(file, ['services', name, 'ports', index]);
|
|
280
295
|
if (!known)
|
|
281
|
-
bases.set(
|
|
282
|
-
else
|
|
283
|
-
known.
|
|
296
|
+
bases.set(variable, { base, files: [source], at: [where] });
|
|
297
|
+
else {
|
|
298
|
+
if (!known.files.includes(source))
|
|
299
|
+
known.files.push(source);
|
|
300
|
+
known.at.push(where);
|
|
301
|
+
}
|
|
284
302
|
}
|
|
285
303
|
}
|
|
286
304
|
}
|
|
287
|
-
for (const
|
|
288
|
-
|
|
289
|
-
bases.
|
|
305
|
+
for (const variable of conflicting) {
|
|
306
|
+
basesComplete = false;
|
|
307
|
+
skipped.push({ source: bases.get(variable).files.join(', '), reason: `publishes \${${variable}} with different defaults, so its base port cannot be read; declare ${variable} under [ports.bases]` });
|
|
308
|
+
bases.delete(variable);
|
|
290
309
|
}
|
|
291
|
-
return { binds, bases };
|
|
310
|
+
return { binds, bases, basesComplete };
|
|
311
|
+
}
|
|
312
|
+
const BYTE_UNITS = { '': 1, b: 1, k: 1024, kb: 1024, m: 1024 ** 2, mb: 1024 ** 2, g: 1024 ** 3, gb: 1024 ** 3 };
|
|
313
|
+
/** Compose's byte notation, as Docker reads it: a byte count, or a number with a binary b, k, m or g unit. */
|
|
314
|
+
function composeBytes(value) {
|
|
315
|
+
if (typeof value === 'number')
|
|
316
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
317
|
+
const match = typeof value === 'string' ? /^(\d+(?:\.\d+)?)\s*([a-z]*)$/i.exec(value.trim()) : null;
|
|
318
|
+
const unit = match ? BYTE_UNITS[match[2].toLowerCase()] : undefined;
|
|
319
|
+
return match && unit !== undefined && Number(match[1]) > 0 ? Number(match[1]) * unit : undefined;
|
|
320
|
+
}
|
|
321
|
+
function composeCpus(value) {
|
|
322
|
+
const cpus = typeof value === 'number' ? value : typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : NaN;
|
|
323
|
+
return Number.isFinite(cpus) && cpus > 0 ? cpus : undefined;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The memory and CPU floor the one proposed Compose file records: each default service's limit
|
|
327
|
+
* times its replicas, summed, because the runtime must hold every service at its limit at once.
|
|
328
|
+
* A service with no limit leaves that resource unrecorded, and a service whose two spellings of a
|
|
329
|
+
* limit disagree is reported instead of guessed between.
|
|
330
|
+
*/
|
|
331
|
+
function composeLimits(file, skipped) {
|
|
332
|
+
// A service behind a profile does not start unless that profile is activated.
|
|
333
|
+
const started = services(file).filter(([, service]) => service.profiles === undefined);
|
|
334
|
+
if (started.length === 0)
|
|
335
|
+
return [];
|
|
336
|
+
const resources = [
|
|
337
|
+
{ key: 'runtime_memory_mb', label: 'memory', long: 'memory', short: 'mem_limit', read: composeBytes,
|
|
338
|
+
total: (sum) => Math.ceil(sum / 1024 ** 2) },
|
|
339
|
+
{ key: 'runtime_cpus', label: 'CPU', long: 'cpus', short: 'cpus', read: composeCpus,
|
|
340
|
+
total: (sum) => Math.round(sum * 1000) / 1000 },
|
|
341
|
+
];
|
|
342
|
+
const facts = [];
|
|
343
|
+
for (const resource of resources) {
|
|
344
|
+
let sum = 0;
|
|
345
|
+
const parts = [];
|
|
346
|
+
const lines = [];
|
|
347
|
+
let complete = true;
|
|
348
|
+
for (const [name, service] of started) {
|
|
349
|
+
const deployPath = ['services', name, 'deploy', 'resources', 'limits', resource.long];
|
|
350
|
+
const deployed = field(field(field(service.deploy, 'resources'), 'limits'), resource.long);
|
|
351
|
+
const short = service[resource.short];
|
|
352
|
+
const [declared, path] = deployed !== undefined ? [deployed, deployPath] : [short, ['services', name, resource.short]];
|
|
353
|
+
const amount = resource.read(declared);
|
|
354
|
+
if (deployed !== undefined && short !== undefined && resource.read(short) !== amount) {
|
|
355
|
+
skipped.push({ source: at(file, deployPath), reason: `service ${name} sets its ${resource.label} limit twice, as ${resource.short} and as deploy.resources.limits.${resource.long}, with different values, so no ${resource.key} is proposed; keep one of them` });
|
|
356
|
+
complete = false;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (amount === undefined) {
|
|
360
|
+
complete = false;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const replicas = field(service.deploy, 'replicas') ?? service.scale ?? 1;
|
|
364
|
+
const count = typeof replicas === 'number' && Number.isInteger(replicas) && replicas >= 0 ? replicas : 1;
|
|
365
|
+
sum += amount * count;
|
|
366
|
+
parts.push(`${name} ${String(declared)}${count === 1 ? '' : ` x${count}`}`);
|
|
367
|
+
lines.push(at(file, path));
|
|
368
|
+
}
|
|
369
|
+
if (complete && sum > 0)
|
|
370
|
+
facts.push({ fields: ['environment.machine'], target: `environment.machine.${resource.key}`, value: resource.total(sum),
|
|
371
|
+
provenance: { kind: 'derived', source: lines.join(', '),
|
|
372
|
+
reason: `the ${resource.label} limits of every service ${file.source} starts by default (${parts.join(', ')}), summed: the runtime must hold each service at its limit at once` } });
|
|
373
|
+
}
|
|
374
|
+
return facts;
|
|
292
375
|
}
|
|
293
376
|
async function detectTestEntry(read, readJson, present, node) {
|
|
294
377
|
for (const name of ['MODULE.bazel', 'WORKSPACE.bazel', 'WORKSPACE']) {
|
|
@@ -415,6 +498,20 @@ export async function deriveInferredFacts(input) {
|
|
|
415
498
|
}
|
|
416
499
|
};
|
|
417
500
|
const composeFiles = [];
|
|
501
|
+
// Compose files are read with their line positions, so a derived value names the line it came from.
|
|
502
|
+
const parseCompose = (source, text) => {
|
|
503
|
+
const lineCounter = new LineCounter();
|
|
504
|
+
const parsed = parseDocument(text, { lineCounter });
|
|
505
|
+
const error = parsed.errors[0];
|
|
506
|
+
if (error) {
|
|
507
|
+
skipped.push({ source, reason: `is not valid YAML (${error.name})` });
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
return { source, document: parsed.toJS(), line: path => {
|
|
511
|
+
const offset = parsed.getIn(path, true)?.range?.[0];
|
|
512
|
+
return offset === undefined ? undefined : lineCounter.linePos(offset).line;
|
|
513
|
+
} };
|
|
514
|
+
};
|
|
418
515
|
const seenServices = new Set();
|
|
419
516
|
const readOrigin = async (directory) => {
|
|
420
517
|
const marker = relative(directory, '.git');
|
|
@@ -555,14 +652,14 @@ export async function deriveInferredFacts(input) {
|
|
|
555
652
|
const text = await read(directory.path, name);
|
|
556
653
|
if (text === undefined)
|
|
557
654
|
continue;
|
|
558
|
-
const
|
|
559
|
-
if (
|
|
655
|
+
const file = parseCompose(source, text);
|
|
656
|
+
if (file !== undefined) {
|
|
560
657
|
if (source === '..' || source.startsWith('../') || isAbsolute(source)) {
|
|
561
658
|
skipped.push({ source,
|
|
562
659
|
reason: `is outside the cockpit root, so environment.compose_files cannot represent it; write a compose.yml at the cockpit root that reaches each checkout through its sibling variable${slug === undefined ? '' : ` (such as \${${deriveSiblingVariable(slug)}})`} and answer environment.compose_files = ["compose.yml"]; then rerun ${initCommand({ root: configurationRoot })} with the options you ran it with` });
|
|
563
660
|
}
|
|
564
661
|
else
|
|
565
|
-
composeFiles.push(
|
|
662
|
+
composeFiles.push(file);
|
|
566
663
|
}
|
|
567
664
|
break;
|
|
568
665
|
}
|
|
@@ -642,19 +739,24 @@ export async function deriveInferredFacts(input) {
|
|
|
642
739
|
? `${placed}; acquire "auto" with its origin remote, so setup clones it where a checkout is absent; add its purpose`
|
|
643
740
|
: `${placed}; acquire "none" because ${origin.reason}; add its purpose` } });
|
|
644
741
|
}
|
|
645
|
-
if (compose.bases.size > 0) {
|
|
646
|
-
const
|
|
647
|
-
const publishers =
|
|
742
|
+
if (composeFiles.length > 0 && (compose.bases.size > 0 || compose.basesComplete)) {
|
|
743
|
+
const files = [...new Set([...compose.bases.values()].flatMap(row => row.files))];
|
|
744
|
+
const publishers = files.map(file => `${file} publishes ${[...compose.bases].filter(([, row]) => row.files.includes(file)).map(([name]) => name).join(', ')}`);
|
|
648
745
|
facts.push({ fields: ['ports.bases'], target: 'ports.bases', value: Object.fromEntries([...compose.bases].map(([name, row]) => [name, row.base])),
|
|
649
|
-
provenance:
|
|
650
|
-
|
|
746
|
+
provenance: compose.bases.size === 0
|
|
747
|
+
? { kind: 'derived', source: composeFiles.map(file => file.source).join(', '),
|
|
748
|
+
reason: 'no service publishes a host port, so no stack needs a base port' }
|
|
749
|
+
: { kind: compose.basesComplete ? 'derived' : 'inferred', source: [...new Set([...compose.bases.values()].flatMap(row => row.at))].join(', '),
|
|
750
|
+
reason: `${publishers.join('; ')} from variables with these defaults; check requires every published host port to come from a [ports.bases] variable, so each stack leases its own${compose.basesComplete ? '' : '; a published host port could not be read (see inferenceSkipped), so this map is incomplete'}` } });
|
|
651
751
|
}
|
|
652
752
|
// compose-first resolves every relative path against the first listed file's directory, so one
|
|
653
753
|
// product Compose file can be run in place but several cannot be listed together.
|
|
654
|
-
if (composeFiles.length === 1)
|
|
754
|
+
if (composeFiles.length === 1) {
|
|
655
755
|
facts.unshift({ fields: ['environment.compose_files'], target: 'environment.compose_files', value: [composeFiles[0].source],
|
|
656
|
-
provenance: { kind: '
|
|
756
|
+
provenance: { kind: 'derived', source: composeFiles[0].source,
|
|
657
757
|
reason: 'a Compose file already exists, so cockpit can run it as written' } });
|
|
758
|
+
facts.push(...composeLimits(composeFiles[0], skipped));
|
|
759
|
+
}
|
|
658
760
|
else if (composeFiles.length > 1)
|
|
659
761
|
skipped.push({ source: composeFiles.map(file => file.source).join(', '),
|
|
660
762
|
reason: `are Compose files in different repositories, and compose-first resolves every relative path against the first listed file's directory, so they cannot be listed together; write one compose.yml at the cockpit root that runs their services, reaching each checkout through its sibling variable (${[...siblingPaths.keys()].map(name => `\${${name}}`).join(', ')}), and answer environment.compose_files = ["compose.yml"]` });
|
|
@@ -703,6 +805,7 @@ export async function composeFindings(input) {
|
|
|
703
805
|
continue;
|
|
704
806
|
const health = service.healthcheck;
|
|
705
807
|
services.set(id, { id, ports, mountSources: [], build: service.build !== undefined,
|
|
808
|
+
containerName: typeof service.container_name === 'string' ? service.container_name : null,
|
|
706
809
|
image: typeof service.image === 'string' ? service.image : null,
|
|
707
810
|
healthcheck: health !== undefined && health !== null && health.disable !== true && !(Array.isArray(health.test) && health.test[0] === 'NONE') });
|
|
708
811
|
}
|
|
@@ -710,6 +813,7 @@ export async function composeFindings(input) {
|
|
|
710
813
|
const bases = draft.ports?.bases;
|
|
711
814
|
const found = await assessComposeTopology([...services.values()], { projectDirectory: composeProjectDirectory(input.root, references),
|
|
712
815
|
portVariables: bases !== null && typeof bases === 'object' ? Object.keys(bases) : [], siblings: [], hostPaths: [] });
|
|
713
|
-
return [...unread, ...found.filter(row => row.code === 'COMPOSE_PORT_NOT_LEASED' || row.code === '
|
|
816
|
+
return [...unread, ...found.filter(row => row.code === 'COMPOSE_PORT_NOT_LEASED' || row.code === 'COMPOSE_CONTAINER_NAME_FIXED' || row.code === 'COMPOSE_IMAGE_TAG_SHARED'
|
|
817
|
+
|| row.code === 'COMPOSE_HEALTHCHECK_MISSING')
|
|
714
818
|
.map(row => ({ ...row, severity: 'warning' }))];
|
|
715
819
|
}
|
package/dist/init/draft.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Diagnostic } from '../diagnostics.js';
|
|
2
|
-
import { type AnswerProvenance, type UnconfirmedAnswer } from './render.js';
|
|
2
|
+
import { type AnswerProvenance, type DerivedAnswer, type UnconfirmedAnswer } from './render.js';
|
|
3
3
|
/**
|
|
4
4
|
* The directory holding the repositories, and optionally the direct children to observe. A cockpit
|
|
5
5
|
* created beside a product repository observes only that repository in their shared parent, which
|
|
@@ -11,10 +11,10 @@ export interface EstateObservationInput {
|
|
|
11
11
|
readonly repositories?: readonly string[];
|
|
12
12
|
readonly rootIsRepository?: true;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
interface EstateObservationAdapters {
|
|
15
15
|
readonly observe: (input: EstateObservationInput) => Promise<unknown>;
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
type EstateClassification = 'one-sibling' | 'incomplete-multi-repository' | 'no-runnable-product' | 'ready-multi-repository';
|
|
18
18
|
export interface EstateClassificationResult {
|
|
19
19
|
readonly classification: EstateClassification;
|
|
20
20
|
readonly sourceObservationSha256: string;
|
|
@@ -48,32 +48,34 @@ interface OutcomeBase {
|
|
|
48
48
|
result: {
|
|
49
49
|
document: string;
|
|
50
50
|
unconfirmed?: readonly UnconfirmedAnswer[];
|
|
51
|
+
derived?: readonly DerivedAnswer[];
|
|
51
52
|
} | null;
|
|
52
53
|
status: number;
|
|
53
54
|
question?: DocumentConfigurationQuestion | null;
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
+
interface CompleteDocumentOutcome extends OutcomeBase {
|
|
56
57
|
kind: 'complete';
|
|
57
58
|
artifact: string;
|
|
58
59
|
result: {
|
|
59
60
|
document: string;
|
|
60
61
|
unconfirmed?: readonly UnconfirmedAnswer[];
|
|
62
|
+
derived?: readonly DerivedAnswer[];
|
|
61
63
|
};
|
|
62
64
|
status: 0;
|
|
63
65
|
}
|
|
64
|
-
|
|
66
|
+
interface GapDraftOutcome extends OutcomeBase {
|
|
65
67
|
kind: 'gap-draft';
|
|
66
68
|
artifact: string;
|
|
67
69
|
result: {
|
|
68
70
|
document: string;
|
|
69
71
|
};
|
|
70
72
|
}
|
|
71
|
-
|
|
73
|
+
interface SkippedScopeOutcome extends OutcomeBase {
|
|
72
74
|
kind: 'skipped-scope';
|
|
73
75
|
artifact: null;
|
|
74
76
|
result: null;
|
|
75
77
|
}
|
|
76
|
-
|
|
78
|
+
interface RefusedDocumentOutcome extends OutcomeBase {
|
|
77
79
|
kind: 'refusal';
|
|
78
80
|
artifact: null;
|
|
79
81
|
result: null;
|
|
@@ -88,15 +90,15 @@ export interface DocumentCommandInput {
|
|
|
88
90
|
/** How init observed the estate when it is not the cockpit root's children: a dedicated or an in-repo cockpit. */
|
|
89
91
|
estate?: EstateObservationInput;
|
|
90
92
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
type DocumentConfigurationField = 'cockpit.name' | 'sibling' | 'estate.runnable' | 'ports.bases' | 'environment.install_policies' | 'environment.test_data' | 'environment.compose_files' | 'environment.machine' | 'environment.posture' | 'testing' | 'guards' | 'configuration.defaults';
|
|
94
|
+
interface DocumentConfigurationQuestion {
|
|
93
95
|
readonly id: `question:${string}`;
|
|
94
96
|
readonly field: DocumentConfigurationField;
|
|
95
97
|
readonly source: string;
|
|
96
98
|
readonly why: string;
|
|
97
99
|
readonly expectedSha256?: string;
|
|
98
100
|
}
|
|
99
|
-
export type { AnswerProvenance, UnconfirmedAnswer } from './render.js';
|
|
101
|
+
export type { AnswerProvenance, DerivedAnswer, UnconfirmedAnswer } from './render.js';
|
|
100
102
|
export declare function isAnswerProvenance(value: unknown): value is AnswerProvenance;
|
|
101
103
|
export interface DocumentConfigurationAnswer {
|
|
102
104
|
readonly questionId: string;
|
|
@@ -104,7 +106,7 @@ export interface DocumentConfigurationAnswer {
|
|
|
104
106
|
readonly sha256: string;
|
|
105
107
|
readonly provenance: AnswerProvenance;
|
|
106
108
|
}
|
|
107
|
-
|
|
109
|
+
interface DocumentConfigurationInput {
|
|
108
110
|
readonly sourceObservationSha256: string;
|
|
109
111
|
readonly classification?: EstateClassification;
|
|
110
112
|
readonly answers: readonly DocumentConfigurationAnswer[];
|
package/dist/init/draft.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { unconfirmedFix } from '../guards/answer-confirmation.js';
|
|
2
3
|
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
|
3
4
|
import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
5
|
import { parse as parseYaml } from 'yaml';
|
|
5
6
|
import { initCommand } from '../fix-catalogue.js';
|
|
7
|
+
import { canonicalJson } from '../canonical-json.js';
|
|
6
8
|
import { readBoundedInput, schemaIssueSummary, STRUCTURED_INPUT_BYTE_LIMIT } from '../config/load.js';
|
|
7
9
|
import { errorCause, snapshotJson, RefusalError, tryParseJson, hasErrorCode } from '../environment/contract.js';
|
|
8
10
|
import { judgePostureDeclaration } from '../environment/posture-observation.js';
|
|
@@ -10,7 +12,7 @@ import { isSensitiveField, redactSensitiveText } from '../environment/sensitive.
|
|
|
10
12
|
import { CockpitConfig, Guards, InstallPolicy, MachineFloor, Testing, configurationDefaults } from '../schema.js';
|
|
11
13
|
import { toSiblingRows } from '../emit.js';
|
|
12
14
|
import { gaps, parseRegistry } from '../registry-resolve/registry.js';
|
|
13
|
-
import { ANSWER_PROVENANCES, renderCompleteDocument, renderIncompleteDocument,
|
|
15
|
+
import { ANSWER_PROVENANCES, confirmedBy, renderCompleteDocument, renderIncompleteDocument, unconfirmedReason, } from './render.js';
|
|
14
16
|
import { COMPOSE_NAMES, NODE_LOCKFILE_NAMES, deriveInferredFacts, readDockerfileFacts } from './derive.js';
|
|
15
17
|
import { productPackageManifest } from '../team-metadata.js';
|
|
16
18
|
import { refusalOutcome as refusal } from '../refusal.js';
|
|
@@ -653,18 +655,21 @@ const QUESTION_WHY = {
|
|
|
653
655
|
policies: 'declare one row per toolchain and lockfile with any build or test command the guard should allow',
|
|
654
656
|
testData: 'answer with exactly { seeded_data: boolean, signed_in_users: boolean } to state whether this local setup needs seeded data and signed-in users; each false field records a precise not-declared decision, while true keeps the product-owned declaration work visible without claiming that data or authentication exists',
|
|
655
657
|
composeFiles: 'list the Compose files, relative to the cockpit root, that run the product, such as ["compose.yml"]; cockpit runs them as written. A small app with no Compose file gets a starting one, which it then owns, from npx --no cockpit init <cockpit root> --scaffold-compose <app dir> --port <port>',
|
|
656
|
-
machine: 'declare the machine floor the product needs from the container runtime: runtime_memory_mb, runtime_cpus, free_disk_mb and cold_start_seconds;
|
|
658
|
+
machine: 'declare the machine floor the product needs from the container runtime: runtime_memory_mb, runtime_cpus, free_disk_mb and cold_start_seconds; init proposes memory and CPUs from the Compose file\'s service limits, or from this machine\'s measured runtime where no limit records them; nothing records free disk or the cold start time, so a person states those, or the setup agent marks its choice "agent-default"',
|
|
657
659
|
posture: `preflight refuses a runtime until its posture is declared: addressing is "port" (localhost:<port>) or "hostname"; authentication is "none" (no sign-in), "bypassed" (sign-in turned off locally) or "real"; posture_requirements names what depends on them, each tagged with the posture it needs. For a plain localhost stack with no sign-in, answer ${JSON.stringify({ addressing: 'port', authentication: 'none', posture_requirements: [{ requirement: 'leased-localhost-ports', addressing: 'port' }] })}`,
|
|
658
660
|
testing: 'declare the product\'s test commands as [testing] stages and gates: a targeted gate agents run while iterating and a full gate they run before handing back shared changes; init proposes them from test entry points it finds on disk. Answer "none" when the product has no tests yet: no work can then be graded PASS, and doctor warns until gates are declared',
|
|
659
661
|
guards: 'name the database hosts and live vendor API hosts of every non-local environment the product has (production, staging, shared dev or QA): cockpit runs only locally, so the guard denies agent commands that reach them; answer { "hosted_db_hosts": [], "vendor_endpoint_hosts": [] } to record that none exists',
|
|
660
|
-
defaults: '
|
|
662
|
+
defaults: 'a person confirms the values the draft lists under its person heading (protected branches, guard allow and deny lists, approved MCP servers, resettable volumes, generated host paths, and anything a registry left to a guess); expectedSha256 covers only those, and the setup agent accepts the schema defaults and the cockpit\'s own layout, which bring-up proves',
|
|
661
663
|
};
|
|
662
664
|
/** How a decision comment attributes an answer, so a value the agent chose never reads as one a person stated. */
|
|
663
665
|
const STATED_BY = {
|
|
664
666
|
'operator-confirmed': 'the operator stated that',
|
|
665
667
|
'agent-default': 'the setup agent assumed, as a default, that',
|
|
666
668
|
unattributed: 'the answers file, without saying who chose it, states that',
|
|
669
|
+
derived: 'init read from the product files that',
|
|
667
670
|
};
|
|
671
|
+
const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
672
|
+
const DERIVED_FIX = 'answer the value init proposes for this field under interview.outstanding, whose provenance kind is "derived"; otherwise mark the answer "human" once a person states it, or "agent-default" when you chose it';
|
|
668
673
|
const noGuardHostsDecision = (provenance) => `${STATED_BY[provenance]} no non-local environment (production, staging, shared) has a database or live vendor host for the guard to deny`;
|
|
669
674
|
const runnableWhy = (gaps) => `resolve the exact current runnable gaps: ${gaps.map(gap => `${gap.field} from ${gap.source}`).join('; ')}`;
|
|
670
675
|
/**
|
|
@@ -698,7 +703,11 @@ function configurationQuestion(context, field, why, expectedSha256) {
|
|
|
698
703
|
source: `configuration:${context.sourceObservationSha256}`,
|
|
699
704
|
why, ...(expectedSha256 === undefined ? {} : { expectedSha256 }) };
|
|
700
705
|
}
|
|
701
|
-
|
|
706
|
+
/**
|
|
707
|
+
* The answer to the current question. One marked derived must be exactly the value init read for
|
|
708
|
+
* the field from an exact source: the provenance is checked, never taken on the answers file's word.
|
|
709
|
+
*/
|
|
710
|
+
function readConfigurationAnswer(answers, index, question, derived) {
|
|
702
711
|
const answer = answers[index];
|
|
703
712
|
if (!answer)
|
|
704
713
|
return { kind: 'pending' };
|
|
@@ -708,7 +717,13 @@ function readConfigurationAnswer(answers, index, question) {
|
|
|
708
717
|
const value = JSON.parse(answer.bytes);
|
|
709
718
|
if (containsSensitiveField(value))
|
|
710
719
|
return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'operator answers cannot contain sensitive fields', question.field) };
|
|
711
|
-
|
|
720
|
+
if (answer.provenance !== 'derived')
|
|
721
|
+
return { kind: 'answer', value, provenance: answer.provenance };
|
|
722
|
+
if (derived === undefined)
|
|
723
|
+
return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_NOT_DERIVED', `the ${question.field} answer is marked derived, but init read no complete value for it from an exact source`, question.field, DERIVED_FIX) };
|
|
724
|
+
if (canonicalJson(value) !== canonicalJson(derived.value))
|
|
725
|
+
return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_NOT_DERIVED', `the ${question.field} answer is marked derived, but it is not ${JSON.stringify(derived.value)}, the value init read from ${derived.source}`, question.field, DERIVED_FIX) };
|
|
726
|
+
return { kind: 'answer', value, provenance: answer.provenance, source: derived.source };
|
|
712
727
|
}
|
|
713
728
|
catch {
|
|
714
729
|
return { kind: 'refusal', outcome: refusal(// The parser message would quote the operator's answer.
|
|
@@ -806,20 +821,29 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
806
821
|
: input.estate && root ? relative(root, input.estate.root) || '.' : '.';
|
|
807
822
|
const destination = input.output ? resolve(input.output) : null;
|
|
808
823
|
let answerIndex = 0;
|
|
824
|
+
let inference;
|
|
809
825
|
let siblingRows;
|
|
810
826
|
let siblingAnswerRows = [];
|
|
811
|
-
// Every answer a person did not state is listed in cockpit.toml, so it never reads as theirs
|
|
827
|
+
// Every answer a person did not state is listed in cockpit.toml, so it never reads as theirs; one
|
|
828
|
+
// accepted as read names the file it was read from instead.
|
|
812
829
|
const unconfirmed = [];
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
830
|
+
const derived = [];
|
|
831
|
+
const record = (field, read) => {
|
|
832
|
+
if (read.provenance === 'derived')
|
|
833
|
+
derived.push({ field, source: read.source });
|
|
834
|
+
else if (read.provenance !== 'operator-confirmed')
|
|
835
|
+
unconfirmed.push({ field, provenance: read.provenance });
|
|
836
|
+
};
|
|
837
|
+
const derivedFact = (target) => {
|
|
838
|
+
const fact = inference?.facts.find(row => row.target === target && row.provenance.kind === 'derived');
|
|
839
|
+
return fact && { value: fact.value, source: fact.provenance.source };
|
|
816
840
|
};
|
|
817
841
|
// Always asked, with the directory name as the proposal: the name scopes Compose projects, state
|
|
818
842
|
// and generated files, so renaming after sync is a migration, and setup agents that were never
|
|
819
843
|
// asked renamed the cockpit by hand afterwards.
|
|
820
844
|
const proposedName = cockpitName(root);
|
|
821
845
|
const nameQuestion = configurationQuestion(context, 'cockpit.name', proposedName ? QUESTION_WHY.nameProposed : QUESTION_WHY.name);
|
|
822
|
-
const nameRead = readConfigurationAnswer(configuration.answers, answerIndex, nameQuestion);
|
|
846
|
+
const nameRead = readConfigurationAnswer(configuration.answers, answerIndex, nameQuestion, proposedName === undefined ? undefined : { value: proposedName, source: 'the cockpit root directory name' });
|
|
823
847
|
if (nameRead.kind === 'pending')
|
|
824
848
|
return pendingConfiguration({ name: proposedName, defaults, siblings: detected,
|
|
825
849
|
attributions: inferGreenfieldSiblingRoot(buildAttributions({ name: proposedName, defaults, siblings: detected }, root, defaultSource), siblingsRoot), gaps: [] }, destination, nameQuestion);
|
|
@@ -829,7 +853,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
829
853
|
if (!parsedName.success)
|
|
830
854
|
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the cockpit name answer does not match the schema: ${issueSummary(parsedName.error)}`, nameQuestion.field);
|
|
831
855
|
const name = parsedName.data.name;
|
|
832
|
-
record('cockpit.name', nameRead
|
|
856
|
+
record('cockpit.name', nameRead);
|
|
833
857
|
answerIndex += 1;
|
|
834
858
|
const detectedRows = toSiblingRows(detected);
|
|
835
859
|
const needsSiblingAnswer = detected.length === 0 || gaps([...detected]).some(gap => gap.blocking);
|
|
@@ -854,7 +878,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
854
878
|
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the operator-confirmed sibling declarations are invalid or change detected registry facts', question.field);
|
|
855
879
|
}
|
|
856
880
|
siblingRows = parsed.data;
|
|
857
|
-
record('sibling', read
|
|
881
|
+
record('sibling', read);
|
|
858
882
|
answerIndex += 1;
|
|
859
883
|
}
|
|
860
884
|
else {
|
|
@@ -865,7 +889,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
865
889
|
siblingAnswerRows = detectedRows;
|
|
866
890
|
}
|
|
867
891
|
// Read with the same inference init proposed from, so a defaulted toolchain names the lockfile it overrode.
|
|
868
|
-
|
|
892
|
+
inference = defaultSource === 'estate' && root ? await deriveInferredFacts({ root: resolve(root, siblingsRoot ?? '.'),
|
|
869
893
|
configurationRoot: root, repositories: siblingRows.map(row => ({ slug: row.slug, path: row.dir })) }) : undefined;
|
|
870
894
|
if (estateRunnable.length > 0) {
|
|
871
895
|
const dispositionSlugs = estateRunnable.map(row => row.slug);
|
|
@@ -917,11 +941,11 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
917
941
|
if (configuration.classification === 'one-sibling'
|
|
918
942
|
&& [...runnableResolutions.values()].every(row => !row.runnable))
|
|
919
943
|
return refusal('DOCUMENT_NO_RUNNABLE_PRODUCT', 'the operator-confirmed estate contains no runnable product', question.field);
|
|
920
|
-
record('estate.runnable', read
|
|
944
|
+
record('estate.runnable', read);
|
|
921
945
|
answerIndex += 1;
|
|
922
946
|
}
|
|
923
947
|
const portQuestion = configurationQuestion(context, 'ports.bases', QUESTION_WHY.ports);
|
|
924
|
-
const portRead = readConfigurationAnswer(configuration.answers, answerIndex, portQuestion);
|
|
948
|
+
const portRead = readConfigurationAnswer(configuration.answers, answerIndex, portQuestion, derivedFact('ports.bases'));
|
|
925
949
|
const previewRows = documentedRows(siblingRows);
|
|
926
950
|
const attributions = defaultAttributions(defaults, siblingAnswerRows, detected, root, defaultSource, siblingsRoot, inference);
|
|
927
951
|
if (portRead.kind === 'pending')
|
|
@@ -932,7 +956,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
932
956
|
const parsedPorts = CockpitConfig.shape.ports.safeParse({ ...defaults.ports, bases: portRead.value });
|
|
933
957
|
if (!parsedPorts.success)
|
|
934
958
|
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed port bases do not match the schema: ${issueSummary(parsedPorts.error)}`, portQuestion.field);
|
|
935
|
-
record('ports.bases', portRead
|
|
959
|
+
record('ports.bases', portRead);
|
|
936
960
|
answerIndex += 1;
|
|
937
961
|
let environment = defaults.environment;
|
|
938
962
|
let testData;
|
|
@@ -949,7 +973,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
949
973
|
const parsedPolicies = InstallPolicy.array().max(256).safeParse(policyRead.value);
|
|
950
974
|
if (!parsedPolicies.success)
|
|
951
975
|
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed install policies do not match the schema: ${issueSummary(parsedPolicies.error)}`, policyQuestion.field);
|
|
952
|
-
record('environment.install_policies', policyRead
|
|
976
|
+
record('environment.install_policies', policyRead);
|
|
953
977
|
answerIndex += 1;
|
|
954
978
|
if (registrySha256 === null) {
|
|
955
979
|
const testDataQuestion = configurationQuestion(context, 'environment.test_data', QUESTION_WHY.testData);
|
|
@@ -967,11 +991,11 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
967
991
|
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the local test-data answer must be exactly { seeded_data: boolean, signed_in_users: boolean }', testDataQuestion.field);
|
|
968
992
|
testData = { seeded_data: value.seeded_data,
|
|
969
993
|
signed_in_users: value.signed_in_users, provenance: testDataRead.provenance };
|
|
970
|
-
record('environment.test_data', testDataRead
|
|
994
|
+
record('environment.test_data', testDataRead);
|
|
971
995
|
answerIndex += 1;
|
|
972
996
|
}
|
|
973
997
|
const composeQuestion = configurationQuestion(context, 'environment.compose_files', QUESTION_WHY.composeFiles);
|
|
974
|
-
const composeRead = readConfigurationAnswer(configuration.answers, answerIndex, composeQuestion);
|
|
998
|
+
const composeRead = readConfigurationAnswer(configuration.answers, answerIndex, composeQuestion, derivedFact('environment.compose_files'));
|
|
975
999
|
if (composeRead.kind === 'pending')
|
|
976
1000
|
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
977
1001
|
attributions, gaps: [] }, destination, composeQuestion);
|
|
@@ -987,12 +1011,11 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
987
1011
|
const defaultIndex = attributions.findIndex(row => row.target === 'environment.compose_files');
|
|
988
1012
|
if (defaultIndex >= 0)
|
|
989
1013
|
attributions.splice(defaultIndex, 1);
|
|
990
|
-
record('environment.compose_files', composeRead
|
|
1014
|
+
record('environment.compose_files', composeRead);
|
|
991
1015
|
answerIndex += 1;
|
|
992
1016
|
// Every runtime command refuses a declared runtime with no floor, so a configuration that
|
|
993
|
-
// declares one is incomplete until
|
|
994
|
-
//
|
|
995
|
-
// reports this machine's capacity, not what the product needs.
|
|
1017
|
+
// declares one is incomplete until it is answered. The interview proposes memory and CPUs,
|
|
1018
|
+
// but free disk and the cold start time have no source, so the answer is never derived.
|
|
996
1019
|
const machineQuestion = configurationQuestion(context, 'environment.machine', QUESTION_WHY.machine);
|
|
997
1020
|
const machineRead = readConfigurationAnswer(configuration.answers, answerIndex, machineQuestion);
|
|
998
1021
|
if (machineRead.kind === 'pending')
|
|
@@ -1004,7 +1027,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
1004
1027
|
if (!parsedMachine.success)
|
|
1005
1028
|
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed machine floor does not match the schema: ${issueSummary(parsedMachine.error)}`, machineQuestion.field);
|
|
1006
1029
|
environment = { ...environment, machine: parsedMachine.data };
|
|
1007
|
-
record('environment.machine', machineRead
|
|
1030
|
+
record('environment.machine', machineRead);
|
|
1008
1031
|
answerIndex += 1;
|
|
1009
1032
|
const postureQuestion = configurationQuestion(context, 'environment.posture', QUESTION_WHY.posture);
|
|
1010
1033
|
const postureRead = readConfigurationAnswer(configuration.answers, answerIndex, postureQuestion);
|
|
@@ -1027,7 +1050,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
1027
1050
|
if (postureFailures.length > 0)
|
|
1028
1051
|
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed posture would fail preflight: ${postureFailures.map(row => row.reason).join('; ')}`, postureQuestion.field);
|
|
1029
1052
|
environment = parsedPosture.data;
|
|
1030
|
-
record('environment.posture', postureRead
|
|
1053
|
+
record('environment.posture', postureRead);
|
|
1031
1054
|
answerIndex += 1;
|
|
1032
1055
|
// Asked, never defaulted: a cockpit with no gate cannot grade work, and "none" is a decision
|
|
1033
1056
|
// a person makes, not the absence of an answer.
|
|
@@ -1049,7 +1072,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
1049
1072
|
? `: ${issueSummary(parsedTesting.error)}` : ''}`, testingQuestion.field);
|
|
1050
1073
|
testing = parsedTesting.data;
|
|
1051
1074
|
}
|
|
1052
|
-
record('testing', testingRead
|
|
1075
|
+
record('testing', testingRead);
|
|
1053
1076
|
answerIndex += 1;
|
|
1054
1077
|
}
|
|
1055
1078
|
// Left as schema defaults, both lists are empty and the guard denies nothing by host, which no
|
|
@@ -1069,7 +1092,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
1069
1092
|
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed guard hosts must be exactly { hosted_db_hosts, vendor_endpoint_hosts }${parsedGuards
|
|
1070
1093
|
? `: ${issueSummary(parsedGuards.error)}` : ''}`, guardQuestion.field);
|
|
1071
1094
|
const noGuardHosts = parsedGuards.data.hosted_db_hosts.length === 0 && parsedGuards.data.vendor_endpoint_hosts.length === 0;
|
|
1072
|
-
record('guards', guardRead
|
|
1095
|
+
record('guards', guardRead);
|
|
1073
1096
|
answerIndex += 1;
|
|
1074
1097
|
const candidate = CockpitConfig.safeParse({ ...(unconfirmed.length === 0 ? {} : { unconfirmed: unconfirmed.map(row => row.field) }),
|
|
1075
1098
|
...defaults, cockpit: { ...defaults.cockpit, name,
|
|
@@ -1083,7 +1106,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
1083
1106
|
|| configuration.classification === 'incomplete-multi-repository') && candidate.data.sibling.length < 2) {
|
|
1084
1107
|
return refusal('DOCUMENT_CONFIGURATION_APPLICABILITY_INVALID', 'the sibling declarations do not match the current estate classification', 'sibling');
|
|
1085
1108
|
}
|
|
1086
|
-
const expectedSha256 = confirmationDigest(attributions);
|
|
1109
|
+
const expectedSha256 = confirmationDigest(attributions.filter(row => confirmedBy(row) === 'person'));
|
|
1087
1110
|
const defaultsQuestion = configurationQuestion(context, 'configuration.defaults', QUESTION_WHY.defaults, expectedSha256);
|
|
1088
1111
|
const defaultsRead = readConfigurationAnswer(configuration.answers, answerIndex, defaultsQuestion);
|
|
1089
1112
|
if (defaultsRead.kind === 'pending')
|
|
@@ -1114,12 +1137,12 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
1114
1137
|
: 'signed-in users: required but unresolved; declare seeded personas and browser authentication states before claiming signed-in testing'
|
|
1115
1138
|
: `signed-in users: not declared because ${STATED_BY[testData.provenance]} this local setup does not need them`] : []),
|
|
1116
1139
|
];
|
|
1117
|
-
const artifact = renderCompleteDocument(candidate.data, SCHEMA_DIRECTIVE, decisions, unconfirmed);
|
|
1140
|
+
const artifact = renderCompleteDocument(candidate.data, SCHEMA_DIRECTIVE, decisions, unconfirmed, derived);
|
|
1118
1141
|
const diagnostics = diagnosticsFor(attributions, []);
|
|
1119
1142
|
for (const row of unconfirmed)
|
|
1120
1143
|
diagnostics.push({ severity: 'warning', code: 'DOCUMENT_ANSWER_UNCONFIRMED', target: row.field,
|
|
1121
1144
|
message: `${unconfirmedReason(row)}, so cockpit.toml lists it under unconfirmed and doctor warns until a person confirms it`,
|
|
1122
|
-
fix:
|
|
1145
|
+
fix: unconfirmedFix([row.field]) });
|
|
1123
1146
|
if (noGuardHosts)
|
|
1124
1147
|
diagnostics.push({ severity: 'info', code: 'DOCUMENT_GUARD_HOSTS_NONE', target: 'guards',
|
|
1125
1148
|
message: `${noGuardHostsDecision(guardRead.provenance)}, so the guard denies no command by host`,
|
|
@@ -1129,7 +1152,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
|
|
|
1129
1152
|
message: 'no runnable product was observed, so this configuration declares no runtime environment and no Compose file: sync publishes the harness surfaces only, and up, logs and the other runtime commands refuse',
|
|
1130
1153
|
fix: 'once a product runs, declare environment.compose_files and run npx --no cockpit sync --config <your cockpit.toml>' });
|
|
1131
1154
|
return { kind: 'complete', artifact, destination, diagnostics,
|
|
1132
|
-
result: { document: artifact, ...(unconfirmed.length === 0 ? {} : { unconfirmed:
|
|
1155
|
+
result: { document: artifact, ...(unconfirmed.length === 0 ? {} : { unconfirmed }), ...(derived.length === 0 ? {} : { derived }) },
|
|
1133
1156
|
status: 0, question: null };
|
|
1134
1157
|
}
|
|
1135
1158
|
export async function draftFromRegistry(input, estateGaps = [], estateRunnable = []) {
|