mikser-io 9.66.0 → 9.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +5 -1
- package/app.js +18 -8
- package/docs/diagnostics.md +8 -0
- package/package.json +1 -1
- package/src/engine.js +128 -103
- package/src/instance.js +60 -25
package/CLAUDE.md
CHANGED
|
@@ -242,7 +242,11 @@ brevity.
|
|
|
242
242
|
EINVAL`. chmod 0600, so the filesystem permission is the access
|
|
243
243
|
decision. A forwarded build RESCANS (`runtime.rebuild()`), never
|
|
244
244
|
drains: a client can beat the inotify event for the file it just
|
|
245
|
-
wrote.
|
|
245
|
+
wrote. Report-only commands (`--tool`, `--tools`, `--verify`,
|
|
246
|
+
`--explain`) forward too — they read, so a local run damaged nothing,
|
|
247
|
+
but a catalogue another process is mid-write in is not one anyone can
|
|
248
|
+
answer from. `runReportOnly()` in engine.js is the one implementation
|
|
249
|
+
both paths call. Exit code comes from `renderErrorCount()`, not
|
|
246
250
|
`process.exitCode` — the engine suppresses that in watch mode by
|
|
247
251
|
design. Config mismatch is refused by resolved PATH; config drift
|
|
248
252
|
under a running instance is detected by stat over `configCoverage`.
|
package/app.js
CHANGED
|
@@ -24,27 +24,37 @@ function locate(argv) {
|
|
|
24
24
|
}
|
|
25
25
|
return null
|
|
26
26
|
}
|
|
27
|
-
const has = (...names) => names.some(n => argv.includes(n))
|
|
27
|
+
const has = (...names) => names.some(n => argv.includes(n) || argv.some(a => names.some(x => a.startsWith(`${x}=`))))
|
|
28
|
+
|
|
29
|
+
// What to ask the instance for. Report-only commands go over the same
|
|
30
|
+
// socket as a build: they read, so running them locally never damaged
|
|
31
|
+
// anything, but a catalogue being written by another process is not a
|
|
32
|
+
// catalogue anyone can answer from — a --verify against a half-finished
|
|
33
|
+
// cycle reports drift that is not there.
|
|
34
|
+
const tool = value('--tool')
|
|
35
|
+
const explain = value('--explain')
|
|
36
|
+
const request = has('--tools') ? { type: 'report', tools: true, json: has('--json') }
|
|
37
|
+
: tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
|
|
38
|
+
: explain ? { type: 'report', explain, json: has('--json') }
|
|
39
|
+
: has('--verify') ? { type: 'report', verify: true, json: has('--json') }
|
|
40
|
+
: { type: 'build', clear: has('--clear') }
|
|
41
|
+
|
|
28
42
|
return {
|
|
29
43
|
workingFolder: value('--working-folder', '-i') ?? '.',
|
|
30
44
|
config: value('--config', '-c') ?? 'mikser.config.js',
|
|
31
|
-
clear: has('--clear'),
|
|
32
45
|
// Commander's negated form: `attach` is true unless --no-attach said so.
|
|
33
46
|
attach: has('--no-attach') ? false : true,
|
|
34
|
-
|
|
35
|
-
// tree, and their handlers exit the process themselves. Left local —
|
|
36
|
-
// the guard in setup() still says an instance is there.
|
|
37
|
-
reportOnly: has('--tool', '--tools', '--verify', '--explain'),
|
|
47
|
+
request,
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
async function main() {
|
|
42
52
|
const where = locate(process.argv.slice(2))
|
|
43
|
-
if (where.attach !== false
|
|
53
|
+
if (where.attach !== false) {
|
|
44
54
|
const code = await forward({
|
|
45
55
|
workingFolder: path.resolve(where.workingFolder),
|
|
46
56
|
config: path.resolve(where.workingFolder, where.config),
|
|
47
|
-
|
|
57
|
+
request: where.request,
|
|
48
58
|
})
|
|
49
59
|
// null means nobody was listening — carry on exactly as before.
|
|
50
60
|
if (code !== null) process.exit(code)
|
package/docs/diagnostics.md
CHANGED
|
@@ -759,6 +759,14 @@ Three things it refuses or reports rather than guessing:
|
|
|
759
759
|
- **A folder held by someone else.** `--no-attach` runs a private engine
|
|
760
760
|
anyway — for checking that a cold start works — and says the folder is held.
|
|
761
761
|
|
|
762
|
+
`--tool`, `--tools`, `--verify` and `--explain` forward as well, and for a
|
|
763
|
+
different reason than builds do. They only read, so running one locally never
|
|
764
|
+
damaged anything — it just could not be trusted: on a large site a local
|
|
765
|
+
`--verify` reads a catalogue the instance is halfway through writing and
|
|
766
|
+
reports drift that is a cycle in progress. The instance has the settled state
|
|
767
|
+
and the config that produced it. Exit codes cross the socket unchanged, so
|
|
768
|
+
`--explain` still answers 3 for an entity that is not there.
|
|
769
|
+
|
|
762
770
|
A forwarded build **rescans**; it does not drain what the watcher happened to
|
|
763
771
|
queue. A client that writes a file and immediately asks can beat the file
|
|
764
772
|
event, and draining would then build without the change that prompted the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.67.0",
|
|
4
4
|
"description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
package/src/engine.js
CHANGED
|
@@ -89,6 +89,127 @@ function workerSafeOptions(opts) {
|
|
|
89
89
|
return result
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// The report-only commands, as functions that RETURN their exit code.
|
|
93
|
+
//
|
|
94
|
+
// They used to be inline here and call process.exit, which is fine for a
|
|
95
|
+
// process whose only job is to answer one question and stop. It is not fine
|
|
96
|
+
// for the instance that has to answer the same question on behalf of a client
|
|
97
|
+
// and stay alive — and answering it there is the point, because a local run
|
|
98
|
+
// reads a catalogue another process is in the middle of writing.
|
|
99
|
+
//
|
|
100
|
+
// `request` carries the CLIENT's arguments. Reading runtime.options here would
|
|
101
|
+
// answer with the instance's own flags, which are whatever it happened to be
|
|
102
|
+
// started with.
|
|
103
|
+
export async function runReportOnly(request = {}) {
|
|
104
|
+
const logger = useLogger()
|
|
105
|
+
const {
|
|
106
|
+
tools = runtime.options.tools,
|
|
107
|
+
tool = runtime.options.tool,
|
|
108
|
+
toolArgs = runtime.options.toolArgs,
|
|
109
|
+
json = runtime.options.json,
|
|
110
|
+
explain = runtime.options.explain,
|
|
111
|
+
verify = runtime.options.verify,
|
|
112
|
+
} = request
|
|
113
|
+
|
|
114
|
+
if (tools) {
|
|
115
|
+
const schemas = toolSchemas()
|
|
116
|
+
if (json) {
|
|
117
|
+
process.stdout.write(JSON.stringify(schemas, null, 2) + '\n')
|
|
118
|
+
} else if (!schemas.length) {
|
|
119
|
+
logger.warn('No tools registered. The mcp plugin registers the standard set; '
|
|
120
|
+
+ 'this flag only lists and invokes what is registered.')
|
|
121
|
+
} else {
|
|
122
|
+
for (const schema of schemas) {
|
|
123
|
+
process.stdout.write(`${schema.name}\n ${String(schema.description).split('\n')[0]}\n`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (tool) {
|
|
130
|
+
// An empty catalog answers every question with a confident nothing —
|
|
131
|
+
// `null`, `total: 0`, "no render claims this destination" — all of
|
|
132
|
+
// which read as "the thing you asked about does not exist" when the
|
|
133
|
+
// truth is "nothing has been built here yet". Said once, before the
|
|
134
|
+
// answer, so it cannot be missed.
|
|
135
|
+
const entityCount = (() => {
|
|
136
|
+
try {
|
|
137
|
+
return useDatabase().handle
|
|
138
|
+
.prepare('SELECT count(*) AS n FROM mikser_entities').get()?.n ?? 0
|
|
139
|
+
} catch { return null }
|
|
140
|
+
})()
|
|
141
|
+
if (entityCount === 0 && !runtime.manifest?.size?.()) {
|
|
142
|
+
logger.warn('The catalog and manifest are empty — no build has run in this working '
|
|
143
|
+
+ 'folder. Tools answer from what the last build recorded, so this one will '
|
|
144
|
+
+ 'report nothing found. Run a build first.')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let args = {}
|
|
148
|
+
if (toolArgs) {
|
|
149
|
+
try {
|
|
150
|
+
args = JSON.parse(toolArgs)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
logger.error('--tool-args is not valid JSON: %s', err.message)
|
|
153
|
+
return 3
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
let result
|
|
157
|
+
try {
|
|
158
|
+
result = await invokeTool(tool, args)
|
|
159
|
+
} catch (err) {
|
|
160
|
+
logger.error('%s', err.message)
|
|
161
|
+
return 3
|
|
162
|
+
}
|
|
163
|
+
process.stdout.write(toolResultText(result) + '\n')
|
|
164
|
+
// A tool that reports failure must not exit 0 — an agent reading CLI
|
|
165
|
+
// output has only the exit code to branch on.
|
|
166
|
+
return toolResultFailed(result) ? 1 : 0
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (explain) {
|
|
170
|
+
// Exit codes:
|
|
171
|
+
// 0 — the entity was found and described
|
|
172
|
+
// 3 — not in the catalog (distinct from --verify's 1/2, which are
|
|
173
|
+
// about output drift; "no such entity" is neither clean nor
|
|
174
|
+
// corrupt, it is a question that could not be answered)
|
|
175
|
+
const { explain: explainEntity, formatExplain } = await import('./explain.js')
|
|
176
|
+
const report = await explainEntity(explain)
|
|
177
|
+
process.stdout.write((json ? JSON.stringify(report, null, 2) : formatExplain(report)) + '\n')
|
|
178
|
+
return report.found ? 0 : 3
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (verify) {
|
|
182
|
+
if (!runtime.manifest) {
|
|
183
|
+
logger.error('Verify: no manifest available — nothing to check against')
|
|
184
|
+
return 2
|
|
185
|
+
}
|
|
186
|
+
const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
|
|
187
|
+
await runtime.manifest.verify()
|
|
188
|
+
const total = runtime.manifest.size()
|
|
189
|
+
|
|
190
|
+
for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
|
|
191
|
+
for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
|
|
192
|
+
e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
|
|
193
|
+
for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
|
|
194
|
+
for (const e of orphaned) logger.warn('Orphan: %s', e.path)
|
|
195
|
+
// Named per destination: "two entities write here" is only actionable
|
|
196
|
+
// if you know which two.
|
|
197
|
+
for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
|
|
198
|
+
|
|
199
|
+
// Level picked from the verdict, because the level IS the marker in
|
|
200
|
+
// pino-pretty's messageFormat: notice renders 🟢, warn 🟡, error 🔴. A
|
|
201
|
+
// fixed `notice` prints a green tick next to the word FAIL, which
|
|
202
|
+
// reads as success at a glance even though the exit code is right.
|
|
203
|
+
const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
|
|
204
|
+
report.call(logger,
|
|
205
|
+
'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
|
|
206
|
+
verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
|
|
207
|
+
return verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null // not a report-only request
|
|
211
|
+
}
|
|
212
|
+
|
|
92
213
|
export async function setup(options) {
|
|
93
214
|
runtime.options.threads = options?.threads !== undefined ? options.threads : 4
|
|
94
215
|
runtime.engine = {
|
|
@@ -291,57 +412,8 @@ export async function setup(options) {
|
|
|
291
412
|
// registry is complete. Nothing is imported, because this exits first,
|
|
292
413
|
// the same way --explain and --verify do.
|
|
293
414
|
if (runtime.options.tools || runtime.options.tool) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (runtime.options.json) {
|
|
297
|
-
process.stdout.write(JSON.stringify(schemas, null, 2) + '\n')
|
|
298
|
-
} else if (!schemas.length) {
|
|
299
|
-
logger.warn('No tools registered. The mcp plugin registers the standard set; '
|
|
300
|
-
+ 'this flag only lists and invokes what is registered.')
|
|
301
|
-
} else {
|
|
302
|
-
for (const schema of schemas) {
|
|
303
|
-
process.stdout.write(`${schema.name}\n ${String(schema.description).split('\n')[0]}\n`)
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
process.exit(0)
|
|
307
|
-
}
|
|
308
|
-
// An empty catalog answers every question with a confident
|
|
309
|
-
// nothing — `null`, `total: 0`, "no render claims this
|
|
310
|
-
// destination" — all of which read as "the thing you asked about
|
|
311
|
-
// does not exist" when the truth is "nothing has been built here
|
|
312
|
-
// yet". Said once, before the answer, so it cannot be missed.
|
|
313
|
-
const entityCount = (() => {
|
|
314
|
-
try {
|
|
315
|
-
return useDatabase().handle
|
|
316
|
-
.prepare('SELECT count(*) AS n FROM mikser_entities').get()?.n ?? 0
|
|
317
|
-
} catch { return null }
|
|
318
|
-
})()
|
|
319
|
-
if (entityCount === 0 && !runtime.manifest?.size?.()) {
|
|
320
|
-
logger.warn('The catalog and manifest are empty — no build has run in this working '
|
|
321
|
-
+ 'folder. Tools answer from what the last build recorded, so this one will '
|
|
322
|
-
+ 'report nothing found. Run a build first.')
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
let args = {}
|
|
326
|
-
if (runtime.options.toolArgs) {
|
|
327
|
-
try {
|
|
328
|
-
args = JSON.parse(runtime.options.toolArgs)
|
|
329
|
-
} catch (err) {
|
|
330
|
-
logger.error('--tool-args is not valid JSON: %s', err.message)
|
|
331
|
-
process.exit(3)
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
let result
|
|
335
|
-
try {
|
|
336
|
-
result = await invokeTool(runtime.options.tool, args)
|
|
337
|
-
} catch (err) {
|
|
338
|
-
logger.error('%s', err.message)
|
|
339
|
-
process.exit(3)
|
|
340
|
-
}
|
|
341
|
-
process.stdout.write(toolResultText(result) + '\n')
|
|
342
|
-
// A tool that reports failure must not exit 0 — an agent reading
|
|
343
|
-
// CLI output has only the exit code to branch on.
|
|
344
|
-
process.exit(toolResultFailed(result) ? 1 : 0)
|
|
415
|
+
const code = await runReportOnly()
|
|
416
|
+
if (code !== null) process.exit(code)
|
|
345
417
|
}
|
|
346
418
|
})
|
|
347
419
|
|
|
@@ -368,58 +440,11 @@ export async function setup(options) {
|
|
|
368
440
|
// 3 — not in the catalog (distinct from --verify's 1/2, which are
|
|
369
441
|
// about output drift; "no such entity" is neither clean nor
|
|
370
442
|
// corrupt, it is a question that could not be answered)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
} else {
|
|
377
|
-
process.stdout.write(formatExplain(report) + '\n')
|
|
378
|
-
}
|
|
379
|
-
process.exit(report.found ? 0 : 3)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (runtime.options.explain) {
|
|
383
|
-
const { explain, formatExplain } = await import('./explain.js')
|
|
384
|
-
const report = await explain(runtime.options.explain)
|
|
385
|
-
if (runtime.options.json) {
|
|
386
|
-
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
387
|
-
} else {
|
|
388
|
-
process.stdout.write(formatExplain(report) + '\n')
|
|
389
|
-
}
|
|
390
|
-
process.exit(report.found ? 0 : 3)
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (runtime.options.verify) {
|
|
395
|
-
if (!runtime.manifest) {
|
|
396
|
-
logger.error('Verify: no manifest available — nothing to check against')
|
|
397
|
-
process.exit(2)
|
|
398
|
-
}
|
|
399
|
-
const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
|
|
400
|
-
await runtime.manifest.verify()
|
|
401
|
-
const total = runtime.manifest.size()
|
|
402
|
-
|
|
403
|
-
for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
|
|
404
|
-
for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
|
|
405
|
-
e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
|
|
406
|
-
for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
|
|
407
|
-
for (const e of orphaned) logger.warn('Orphan: %s', e.path)
|
|
408
|
-
// Named per destination: "two entities write here" is only
|
|
409
|
-
// actionable if you know which two.
|
|
410
|
-
for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
|
|
411
|
-
|
|
412
|
-
// Level picked from the verdict, because the level IS the marker
|
|
413
|
-
// in pino-pretty's messageFormat: notice renders 🟢, warn 🟡,
|
|
414
|
-
// error 🔴. A fixed `notice` prints a green tick next to the word
|
|
415
|
-
// FAIL, which reads as success at a glance even though the exit
|
|
416
|
-
// code is right.
|
|
417
|
-
const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
|
|
418
|
-
report.call(logger,
|
|
419
|
-
'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
|
|
420
|
-
verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
|
|
421
|
-
process.exit(verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0)
|
|
422
|
-
}
|
|
443
|
+
// The same three commands the instance answers over the socket —
|
|
444
|
+
// one implementation, so a forwarded --verify cannot disagree with a
|
|
445
|
+
// local one about what it checked.
|
|
446
|
+
const code = await runReportOnly()
|
|
447
|
+
if (code !== null) process.exit(code)
|
|
423
448
|
})
|
|
424
449
|
|
|
425
450
|
onRender(async (signal) => {
|
package/src/instance.js
CHANGED
|
@@ -34,6 +34,7 @@ import { chmod } from 'node:fs/promises'
|
|
|
34
34
|
import runtime from './runtime.js'
|
|
35
35
|
import { onLoaded } from './lifecycle.js'
|
|
36
36
|
import { renderErrorCount } from './report.js'
|
|
37
|
+
import { runReportOnly } from './engine.js'
|
|
37
38
|
|
|
38
39
|
// Where the endpoint lives.
|
|
39
40
|
//
|
|
@@ -66,7 +67,8 @@ export function socketPath(workingFolder) {
|
|
|
66
67
|
// Newline-delimited JSON, one object per line. Deliberately boring: both ends
|
|
67
68
|
// ship together, so there is nothing to negotiate and no version to carry.
|
|
68
69
|
//
|
|
69
|
-
// → { type: 'build',
|
|
70
|
+
// → { type: 'build', config, clear }
|
|
71
|
+
// → { type: 'report', config, tool, tools, toolArgs, explain, verify, json }
|
|
70
72
|
// ← { type: 'log', chunk } (zero or more, in order)
|
|
71
73
|
// ← { type: 'done', code }
|
|
72
74
|
// ← { type: 'refused', reason, detail }
|
|
@@ -97,7 +99,7 @@ function readFrames(socket, onFrame) {
|
|
|
97
99
|
// which case the caller proceeds exactly as it always did. Called before
|
|
98
100
|
// setup(), so a forwarded command never pays for importing the config or the
|
|
99
101
|
// plugin graph, which is most of what a one-shot spends its time on.
|
|
100
|
-
export function forward({ workingFolder, config,
|
|
102
|
+
export function forward({ workingFolder, config, request }) {
|
|
101
103
|
const endpoint = socketPath(workingFolder)
|
|
102
104
|
|
|
103
105
|
return new Promise((resolve) => {
|
|
@@ -116,7 +118,7 @@ export function forward({ workingFolder, config, clear }) {
|
|
|
116
118
|
resolve(null)
|
|
117
119
|
})
|
|
118
120
|
|
|
119
|
-
socket.on('connect', () => frame(socket, {
|
|
121
|
+
socket.on('connect', () => frame(socket, { ...request, config }))
|
|
120
122
|
|
|
121
123
|
readFrames(socket, (message) => {
|
|
122
124
|
if (message.type === 'log') {
|
|
@@ -219,29 +221,50 @@ async function configStale() {
|
|
|
219
221
|
return null
|
|
220
222
|
}
|
|
221
223
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
224
|
+
// Report-only commands, answered from the live catalogue.
|
|
225
|
+
//
|
|
226
|
+
// These read; they do not write, so running them locally was safe for the
|
|
227
|
+
// FILES. It was not safe for the ANSWER. A local --verify at ten thousand
|
|
228
|
+
// pages reads a catalogue the instance is in the middle of writing and reports
|
|
229
|
+
// drift that is a half-finished cycle, and a local --tool answers from
|
|
230
|
+
// whatever the last build left rather than from what is true now.
|
|
231
|
+
//
|
|
232
|
+
// The instance has the settled state and the config that produced it, so it is
|
|
233
|
+
// the only process that can answer correctly. Same guards as a build: wrong
|
|
234
|
+
// config refuses, drifted config refuses.
|
|
235
|
+
async function serveReport(socket, request, logger) {
|
|
236
|
+
const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
|
|
237
|
+
let code = 0
|
|
238
|
+
try {
|
|
239
|
+
code = await runReportOnly(request) ?? 0
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logger?.error('instance: forwarded report failed — %s', err.message)
|
|
242
|
+
code = 3
|
|
243
|
+
} finally {
|
|
244
|
+
restore()
|
|
232
245
|
}
|
|
246
|
+
frame(socket, { type: 'done', code })
|
|
247
|
+
}
|
|
233
248
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
243
|
-
}
|
|
249
|
+
function refuseConfig(socket, request, wrongConfig) {
|
|
250
|
+
frame(socket, {
|
|
251
|
+
type: 'refused',
|
|
252
|
+
reason: `this instance is running ${wrongConfig}, and you asked for ${path.resolve(request.config)}.`,
|
|
253
|
+
detail: 'Answering would use the wrong config — the accident this refusal exists to prevent. '
|
|
254
|
+
+ 'Stop that instance, or pass --no-attach to run your own.',
|
|
255
|
+
})
|
|
256
|
+
}
|
|
244
257
|
|
|
258
|
+
function refuseStale(socket, movedFile) {
|
|
259
|
+
frame(socket, {
|
|
260
|
+
type: 'refused',
|
|
261
|
+
reason: `this instance's config changed on disk since it started (${movedFile}).`,
|
|
262
|
+
detail: 'It is still running the old one. Restart it, and this command will reach an instance that '
|
|
263
|
+
+ 'matches what you edited.',
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function serveBuild(socket, request, logger) {
|
|
245
268
|
const restore = captureOutput((chunk) => frame(socket, { type: 'log', chunk }))
|
|
246
269
|
let code = 0
|
|
247
270
|
try {
|
|
@@ -304,8 +327,20 @@ export function serveInstance() {
|
|
|
304
327
|
server = net.createServer((socket) => {
|
|
305
328
|
socket.on('error', () => { /* client vanished mid-request */ })
|
|
306
329
|
readFrames(socket, (request) => {
|
|
307
|
-
if (request.type !== 'build') return
|
|
308
|
-
chain = chain.then(
|
|
330
|
+
if (request.type !== 'build' && request.type !== 'report') return
|
|
331
|
+
chain = chain.then(async () => {
|
|
332
|
+
// Both kinds answer for the client's config, not the
|
|
333
|
+
// instance's — a report against the wrong config is the
|
|
334
|
+
// original incident, and it is wrong whether or not it
|
|
335
|
+
// writes anything.
|
|
336
|
+
const wrongConfig = configMismatch(request.config)
|
|
337
|
+
if (wrongConfig) return refuseConfig(socket, request, wrongConfig)
|
|
338
|
+
const movedFile = await configStale()
|
|
339
|
+
if (movedFile) return refuseStale(socket, movedFile)
|
|
340
|
+
return request.type === 'build'
|
|
341
|
+
? serveBuild(socket, request, logger)
|
|
342
|
+
: serveReport(socket, request, logger)
|
|
343
|
+
}).catch(() => {})
|
|
309
344
|
})
|
|
310
345
|
})
|
|
311
346
|
server.on('error', (err) => {
|