@platformos/platformos-mcp-supervisor 0.0.2 → 0.2.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/README.md +105 -0
- package/dist/adapter-input.d.ts +113 -0
- package/dist/adapter-input.js +210 -0
- package/dist/adapter-input.spec.d.ts +1 -0
- package/dist/adapter-input.spec.js +275 -0
- package/dist/bin/args.d.ts +15 -2
- package/dist/bin/args.js +31 -3
- package/dist/bin/args.spec.d.ts +1 -0
- package/dist/bin/args.spec.js +35 -0
- package/dist/bin/platformos-mcp-supervisor.js +6 -2
- package/dist/check-docs.d.ts +21 -0
- package/dist/check-docs.js +38 -0
- package/dist/check-docs.spec.d.ts +1 -0
- package/dist/check-docs.spec.js +87 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +12 -0
- package/dist/cost-model.d.ts +188 -0
- package/dist/cost-model.js +197 -0
- package/dist/cost-model.spec.d.ts +1 -0
- package/dist/cost-model.spec.js +91 -0
- package/dist/deadline.d.ts +27 -0
- package/dist/deadline.js +40 -0
- package/dist/deadline.spec.d.ts +1 -0
- package/dist/deadline.spec.js +83 -0
- package/dist/enrich/enrich.d.ts +105 -0
- package/dist/enrich/enrich.js +136 -0
- package/dist/enrich/enrich.spec.d.ts +1 -0
- package/dist/enrich/enrich.spec.js +184 -0
- package/dist/enrich/symbol.d.ts +16 -0
- package/dist/enrich/symbol.js +58 -0
- package/dist/enrich/symbol.spec.d.ts +1 -0
- package/dist/enrich/symbol.spec.js +89 -0
- package/dist/impact/dependants.d.ts +75 -0
- package/dist/impact/dependants.js +104 -0
- package/dist/impact/diff.d.ts +27 -0
- package/dist/impact/diff.js +33 -0
- package/dist/impact/impact.d.ts +21 -0
- package/dist/impact/impact.js +160 -0
- package/dist/impact/impact.spec.d.ts +1 -0
- package/dist/impact/impact.spec.js +357 -0
- package/dist/impact/project-scan.d.ts +39 -0
- package/dist/impact/project-scan.js +62 -0
- package/dist/impact/project-scan.spec.d.ts +1 -0
- package/dist/impact/project-scan.spec.js +121 -0
- package/dist/index.d.ts +1 -1
- package/dist/lint/fixes.d.ts +28 -0
- package/dist/lint/fixes.js +30 -0
- package/dist/lint/lint-batch.d.ts +100 -0
- package/dist/lint/lint-batch.js +98 -0
- package/dist/lint/lint-batch.spec.d.ts +1 -0
- package/dist/lint/lint-batch.spec.js +503 -0
- package/dist/lint/lint.d.ts +11 -1
- package/dist/lint/lint.js +9 -3
- package/dist/result/assemble.d.ts +11 -9
- package/dist/result/assemble.js +63 -16
- package/dist/result/assemble.spec.d.ts +1 -0
- package/dist/result/assemble.spec.js +319 -0
- package/dist/result/blocking.d.ts +92 -0
- package/dist/result/blocking.js +116 -0
- package/dist/result/blocking.spec.d.ts +1 -0
- package/dist/result/blocking.spec.js +201 -0
- package/dist/result/impact-states.d.ts +19 -0
- package/dist/result/impact-states.js +10 -0
- package/dist/result/response-budget.d.ts +9 -0
- package/dist/result/response-budget.js +133 -0
- package/dist/result/response-budget.spec.d.ts +1 -0
- package/dist/result/response-budget.spec.js +222 -0
- package/dist/result/types.d.ts +212 -104
- package/dist/result/types.js +4 -13
- package/dist/transport/instructions-coverage.spec.d.ts +1 -0
- package/dist/transport/instructions-coverage.spec.js +176 -0
- package/dist/transport/instructions.d.ts +1 -0
- package/dist/transport/instructions.js +150 -0
- package/dist/transport/process-guards.d.ts +43 -0
- package/dist/transport/process-guards.js +63 -0
- package/dist/transport/process-guards.spec.d.ts +1 -0
- package/dist/transport/process-guards.spec.js +200 -0
- package/dist/transport/server.d.ts +6 -1
- package/dist/transport/server.js +21 -10
- package/dist/transport/validate-code.d.ts +50 -15
- package/dist/transport/validate-code.js +238 -33
- package/dist/transport/validate-code.spec.d.ts +1 -0
- package/dist/transport/validate-code.spec.js +2303 -0
- package/dist/validate/batch-bounds.d.ts +34 -0
- package/dist/validate/batch-bounds.js +65 -0
- package/dist/validate/batch-coherence.d.ts +21 -0
- package/dist/validate/batch-coherence.js +54 -0
- package/dist/validate/batch-coherence.spec.d.ts +1 -0
- package/dist/validate/batch-coherence.spec.js +111 -0
- package/dist/validate/validate-buffers.d.ts +27 -0
- package/dist/validate/validate-buffers.js +319 -0
- package/package.json +8 -7
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-level instructions, returned to the client in the `initialize` response
|
|
3
|
+
* and surfaced to the model alongside the tool list.
|
|
4
|
+
*
|
|
5
|
+
* SEPARATE FROM THE TOOL DESCRIPTION, which answers "what does this tool do and how do I
|
|
6
|
+
* call it". These answer "how do I USE this server correctly" — when to reach for it, and
|
|
7
|
+
* how to read an answer. The costly mistakes an agent makes here are interpretation
|
|
8
|
+
* mistakes, not calling mistakes.
|
|
9
|
+
*
|
|
10
|
+
* WHAT MAY BE WRITTEN HERE is a narrow rule (ARCHITECTURE.md §Invariants #6): this server
|
|
11
|
+
* describes ITSELF and never the platform, so a claim belongs here only if NO DIAGNOSTIC
|
|
12
|
+
* COULD CARRY IT —
|
|
13
|
+
*
|
|
14
|
+
* - how to read the answer, since no finding explains the envelope;
|
|
15
|
+
* - a SILENCE, which by definition no diagnostic can convey, and mistaking silence for
|
|
16
|
+
* approval is the failure this server exists to prevent.
|
|
17
|
+
*
|
|
18
|
+
* Platform semantics do not belong here: each is measured to fire a real diagnostic
|
|
19
|
+
* (`transport/instructions-coverage.spec.ts` holds the fixtures) and each diagnostic
|
|
20
|
+
* carries its check's documentation URL, so prose here would be a staler second copy.
|
|
21
|
+
*
|
|
22
|
+
* WRITING RULES: state what to DO, not how the server is built; every claim must be true
|
|
23
|
+
* of the current build, since overstating coverage converts "I do not know" into false
|
|
24
|
+
* confidence; keep it short enough to be read in full, because this is spent context on
|
|
25
|
+
* every session.
|
|
26
|
+
*/
|
|
27
|
+
import { allChecks } from '@platformos/platformos-check-common';
|
|
28
|
+
/**
|
|
29
|
+
* The coverage line, DERIVED so it cannot describe a build that no longer exists.
|
|
30
|
+
*
|
|
31
|
+
* A COUNT, not a roll of check names: an agent does not choose checks, and each finding
|
|
32
|
+
* already names its own. What the count conveys is scale, which is a decision input before
|
|
33
|
+
* the first call.
|
|
34
|
+
*/
|
|
35
|
+
function coverage() {
|
|
36
|
+
return `${allChecks.length} checks`;
|
|
37
|
+
}
|
|
38
|
+
export const SERVER_INSTRUCTIONS = `platformOS code validator.
|
|
39
|
+
|
|
40
|
+
WHEN TO USE
|
|
41
|
+
Call validate_code BEFORE writing or editing any platformOS Liquid, GraphQL or
|
|
42
|
+
YAML file — it validates an in-memory buffer, so call it with the content you are
|
|
43
|
+
about to write, not after writing. This is the primary quality gate. If you are changing several files as one coherent change,
|
|
44
|
+
send them together in a single call (see the tool's \`files\` parameter): files in
|
|
45
|
+
one call can reference each other, so a partial you are creating alongside its
|
|
46
|
+
caller resolves correctly. Sent one at a time, that same edit is reported broken.
|
|
47
|
+
List each file at most once — a changeset cannot hold two versions of one file, so a
|
|
48
|
+
request that names the same file twice is refused rather than guessed at.
|
|
49
|
+
Skipping this tool is the #1 cause of broken platformOS code.
|
|
50
|
+
|
|
51
|
+
HOW TO READ THE RESULT
|
|
52
|
+
|
|
53
|
+
must_fix_before_write
|
|
54
|
+
true -> Do NOT write the file. It will not parse, it will raise at runtime, or
|
|
55
|
+
the deploy converter will reject it — and a converter rejection fails the
|
|
56
|
+
WHOLE changeset, not just this file.
|
|
57
|
+
false -> Nothing BLOCKING was found. This is not a statement that the code is
|
|
58
|
+
correct, only that no known-fatal problem was detected. Keep your own
|
|
59
|
+
judgement.
|
|
60
|
+
|
|
61
|
+
status
|
|
62
|
+
ok | warning | error -> the file WAS checked; these describe what was found.
|
|
63
|
+
not_applicable -> the file was NOT checked at all. This is neither
|
|
64
|
+
approval nor refusal — it carries no opinion about the
|
|
65
|
+
file. Read not_applicable_reason before deciding:
|
|
66
|
+
outside_project - not inside the project this server serves
|
|
67
|
+
unsupported_type - not a file type platformOS lints
|
|
68
|
+
misplaced_source - a platformOS source outside every deployed subtree; nothing
|
|
69
|
+
checked it and nothing will load it either
|
|
70
|
+
ignored - excluded by the project's .platformos-check.yml
|
|
71
|
+
too_large - one buffer, or the request as a whole, is above its size
|
|
72
|
+
limit. Split the file, or send fewer files per call — the
|
|
73
|
+
reason text says which bound was hit
|
|
74
|
+
timed_out - validation was abandoned; retrying may work
|
|
75
|
+
internal_error - your REQUEST was malformed, or the validator hit a bug; the
|
|
76
|
+
reason text says which. A malformed request — both input
|
|
77
|
+
forms at once, neither, or one file listed twice — is yours
|
|
78
|
+
to fix and worth retrying once fixed.
|
|
79
|
+
|
|
80
|
+
errors / warnings / infos
|
|
81
|
+
Each list is ordered by line then column WITHIN ITSELF; the three are not one
|
|
82
|
+
ordered sequence, so concatenating them does not walk the file in order. Columns
|
|
83
|
+
count UTF-16 code units, so an emoji advances the column by 2.
|
|
84
|
+
Note that errors[] can be non-empty while must_fix_before_write is false: some
|
|
85
|
+
errors are real problems that do not stop the file working (an argument a partial
|
|
86
|
+
ignores, a missing asset, a missing image dimension). Fix them when you can; they
|
|
87
|
+
do not block the write.
|
|
88
|
+
Each finding names the check that produced it, and where see_also is present it links
|
|
89
|
+
that check's documentation — read it there rather than guessing at the rule.
|
|
90
|
+
A finding may carry fix (edits that are safe to apply as given) or suggestions
|
|
91
|
+
(alternatives to choose between). Their start_index / end_index are 0-based offsets
|
|
92
|
+
into the buffer you sent — NOT the 1-based line/column on the same finding. Apply
|
|
93
|
+
several edits from the end backwards, or account for the drift.
|
|
94
|
+
|
|
95
|
+
truncated
|
|
96
|
+
Present ONLY when a file had so many findings that the lists were shortened to
|
|
97
|
+
keep the answer a reasonable size; absent means the lists are complete. It gives
|
|
98
|
+
the true total per affected list, so "returned: 40, total: 900" means 860 more
|
|
99
|
+
exist. The lists keep the TOP of the file, where a cascade's root cause usually
|
|
100
|
+
is. status and must_fix_before_write are always computed from ALL findings, never
|
|
101
|
+
from the shortened list, so a truncated answer is never a softer verdict — fix
|
|
102
|
+
what is listed and validate again to see the rest.
|
|
103
|
+
|
|
104
|
+
impact
|
|
105
|
+
What this change BREAKS in files you are NOT editing — the one thing a per-file lint cannot
|
|
106
|
+
see, since it only ever looks at the buffers you sent. Each entry names the file and
|
|
107
|
+
carries the check's own findings: same message, same severity, same documentation link, and
|
|
108
|
+
the same fixes, exactly as if you had validated that file yourself.
|
|
109
|
+
It reports only what your change INTRODUCED. A problem that file already had is never
|
|
110
|
+
listed, however severe — it is not this edit's doing, and it would bury the one that is.
|
|
111
|
+
A break in someone else's file does NOT set must_fix_before_write: your buffer may be
|
|
112
|
+
perfectly correct. Fix the callers, or decide not to; that is yours to weigh.
|
|
113
|
+
status computed the dependants were linted with your change and without it
|
|
114
|
+
not_applicable nothing can reference this file by name
|
|
115
|
+
unavailable could not be run: a failure, a deadline, or a file referenced from
|
|
116
|
+
more text than can be examined in time
|
|
117
|
+
disabled this server runs with --no-impact, so nothing was attempted
|
|
118
|
+
unchecked_dependants appears when a file has more dependants than one request will lint.
|
|
119
|
+
Those were never checked, so an otherwise-clean answer carrying it is a PARTIAL one.
|
|
120
|
+
NOTHING HERE IS A CLEARANCE. An empty impact means no break was found among the dependants
|
|
121
|
+
that are VISIBLE, and a dependant can be invisible: {% render partial_name %} picks its
|
|
122
|
+
target at runtime, so no analysis can resolve it.
|
|
123
|
+
This server never tells you nothing depends on a file — if you are about to delete or
|
|
124
|
+
rename one, search the project yourself.
|
|
125
|
+
|
|
126
|
+
WHAT IS NOT CHECKED
|
|
127
|
+
${coverage()} run against your buffer. Where a finding exists it explains itself and links
|
|
128
|
+
its documentation, so what follows is only the SILENCES — the places where finding nothing is not the same
|
|
129
|
+
as finding it correct, and where no diagnostic can tell you so.
|
|
130
|
+
|
|
131
|
+
Argument types are checked only where the platformOS documentation publishes a type.
|
|
132
|
+
An argument the documentation leaves untyped accepts more than one type and is never
|
|
133
|
+
reported — which is every argument of every core Liquid filter, because those coerce
|
|
134
|
+
rather than refuse. {{ 5 | upcase }} renders and is not reported. That silence is the
|
|
135
|
+
documentation's answer, not a guarantee the value is right.
|
|
136
|
+
|
|
137
|
+
A model schema's property TYPE is checked and an unknown one is reported. The rest of a
|
|
138
|
+
schema's shape is not: an unrecognised top-level key is rejected on deploy and nothing
|
|
139
|
+
reports it, so silence there is not a claim the key is valid. A duplicated property name
|
|
140
|
+
IS accepted by the platform.
|
|
141
|
+
|
|
142
|
+
Duplicate YAML keys are compared the way the platform's own parser resolves them, so
|
|
143
|
+
yes: and true: in one mapping are reported as the one key they become. That comparison
|
|
144
|
+
is NOT exhaustive — a few spellings collide on the platform and are not reported — so
|
|
145
|
+
silence there does not prove two keys are distinct.
|
|
146
|
+
|
|
147
|
+
Coverage is per project: checks can be enabled, disabled or ignored in the project's
|
|
148
|
+
.platformos-check.yml, so a clean result reflects that project's configuration rather
|
|
149
|
+
than a fixed universal standard.`;
|
|
150
|
+
//# sourceMappingURL=instructions.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process lifetime guards: signals and last-resort error handlers.
|
|
3
|
+
*
|
|
4
|
+
* The supervisor is a long-lived stdio process an agent holds open for a whole session, and
|
|
5
|
+
* under Node's default an unhandled rejection is FATAL — from the agent's side the tool
|
|
6
|
+
* simply stops existing mid-session, with nothing in the JSON-RPC stream to explain it.
|
|
7
|
+
*
|
|
8
|
+
* THE SPLIT is deliberately not symmetric:
|
|
9
|
+
*
|
|
10
|
+
* - `unhandledRejection` — LOG AND KEEP SERVING. A rejected background promise does not
|
|
11
|
+
* imply the server's state is corrupt, and staying up beats vanishing.
|
|
12
|
+
* - `uncaughtException` — LOG, SHUT DOWN, EXIT NON-ZERO. The process state may genuinely
|
|
13
|
+
* be corrupt, so continuing to answer would risk returning wrong diagnostics.
|
|
14
|
+
*
|
|
15
|
+
* HARD CONSTRAINT: stdout belongs to the MCP JSON-RPC stream. Everything here logs through
|
|
16
|
+
* the injected {@link Logger}, which writes to stderr; a stray stdout write would corrupt
|
|
17
|
+
* the protocol.
|
|
18
|
+
*
|
|
19
|
+
* `exit` and `emitter` are injected so the handlers can be driven in a test without
|
|
20
|
+
* terminating the runner. The returned uninstall function is wired into `shutdown` so a
|
|
21
|
+
* start/stop/start cycle does not accumulate listeners.
|
|
22
|
+
*/
|
|
23
|
+
import type { Logger } from '../logger.js';
|
|
24
|
+
/** The subset of `process` these guards touch. Narrow, so a test can pass a stub. */
|
|
25
|
+
export interface GuardEmitter {
|
|
26
|
+
on(event: string, listener: (...args: never[]) => void): unknown;
|
|
27
|
+
once(event: string, listener: (...args: never[]) => void): unknown;
|
|
28
|
+
removeListener(event: string, listener: (...args: never[]) => void): unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface ProcessGuardOptions {
|
|
31
|
+
log: Logger;
|
|
32
|
+
/** The server's idempotent teardown. Awaited before a fatal exit. */
|
|
33
|
+
shutdown: (reason?: string) => Promise<void>;
|
|
34
|
+
/** Defaults to `process.exit`. Injected so tests never terminate the runner. */
|
|
35
|
+
exit?: (code: number) => void;
|
|
36
|
+
/** Defaults to `process`. Injected so tests can observe registration. */
|
|
37
|
+
emitter?: GuardEmitter;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Install signal handlers and last-resort error guards. Returns an uninstall
|
|
41
|
+
* function; calling it twice is harmless.
|
|
42
|
+
*/
|
|
43
|
+
export declare function installProcessGuards(options: ProcessGuardOptions): () => void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Signals that mean "stop cleanly"; both exit 0. */
|
|
2
|
+
const TERMINATION_SIGNALS = ['SIGINT', 'SIGTERM'];
|
|
3
|
+
/**
|
|
4
|
+
* Render a thrown/rejected value for the log.
|
|
5
|
+
*
|
|
6
|
+
* A rejection reason is NOT necessarily an Error — `Promise.reject('nope')` and
|
|
7
|
+
* `Promise.reject()` are both legal — so this never assumes `.stack` exists.
|
|
8
|
+
*/
|
|
9
|
+
function describe(value) {
|
|
10
|
+
if (value instanceof Error)
|
|
11
|
+
return value.stack ?? `${value.name}: ${value.message}`;
|
|
12
|
+
// `String(Symbol())` throws; `String(undefined)` does not. Be total.
|
|
13
|
+
try {
|
|
14
|
+
return String(value);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return Object.prototype.toString.call(value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Install signal handlers and last-resort error guards. Returns an uninstall
|
|
22
|
+
* function; calling it twice is harmless.
|
|
23
|
+
*/
|
|
24
|
+
export function installProcessGuards(options) {
|
|
25
|
+
const { log, shutdown } = options;
|
|
26
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
27
|
+
const emitter = options.emitter ?? process;
|
|
28
|
+
const registered = [];
|
|
29
|
+
const onSignal = (signal) => () => {
|
|
30
|
+
void shutdown(signal).finally(() => exit(0));
|
|
31
|
+
};
|
|
32
|
+
// `once`: a second SIGINT while shutting down should not re-enter teardown.
|
|
33
|
+
for (const signal of TERMINATION_SIGNALS) {
|
|
34
|
+
const listener = onSignal(signal);
|
|
35
|
+
emitter.once(signal, listener);
|
|
36
|
+
registered.push({ event: signal, listener });
|
|
37
|
+
}
|
|
38
|
+
// `on`, not `once`: unhandled rejections are survivable and may recur, and each one must
|
|
39
|
+
// be reported.
|
|
40
|
+
const onUnhandledRejection = ((reason) => {
|
|
41
|
+
log(`unhandled promise rejection (server continues): ${describe(reason)}. ` +
|
|
42
|
+
`This is a bug — a background task rejected with nobody awaiting it.`);
|
|
43
|
+
});
|
|
44
|
+
emitter.on('unhandledRejection', onUnhandledRejection);
|
|
45
|
+
registered.push({ event: 'unhandledRejection', listener: onUnhandledRejection });
|
|
46
|
+
const onUncaughtException = ((error) => {
|
|
47
|
+
log(`uncaught exception, shutting down: ${describe(error)}`);
|
|
48
|
+
// Non-zero: a supervisor that died must not look like a clean exit to whatever
|
|
49
|
+
// supervises IT. `finally` so a failing shutdown still exits.
|
|
50
|
+
void shutdown('uncaught exception').finally(() => exit(1));
|
|
51
|
+
});
|
|
52
|
+
emitter.on('uncaughtException', onUncaughtException);
|
|
53
|
+
registered.push({ event: 'uncaughtException', listener: onUncaughtException });
|
|
54
|
+
let uninstalled = false;
|
|
55
|
+
return () => {
|
|
56
|
+
if (uninstalled)
|
|
57
|
+
return;
|
|
58
|
+
uninstalled = true;
|
|
59
|
+
for (const { event, listener } of registered)
|
|
60
|
+
emitter.removeListener(event, listener);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=process-guards.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { installProcessGuards } from './process-guards.js';
|
|
4
|
+
/**
|
|
5
|
+
* TASK-16. Under Node's default an unhandled rejection is FATAL, so one rejected
|
|
6
|
+
* promise on a background path used to take the whole stdio server down — the
|
|
7
|
+
* agent losing the tool mid-session with nothing in the JSON-RPC stream to explain
|
|
8
|
+
* it. These pin the asymmetry that makes the guards correct:
|
|
9
|
+
*
|
|
10
|
+
* - a rejection is survivable -> log, KEEP SERVING
|
|
11
|
+
* - an uncaught exception is not -> log, shut down, exit NON-ZERO
|
|
12
|
+
*
|
|
13
|
+
* `exit` and the emitter are injected throughout, so nothing here can terminate
|
|
14
|
+
* the test runner.
|
|
15
|
+
*/
|
|
16
|
+
const harness = () => {
|
|
17
|
+
const logs = [];
|
|
18
|
+
const exits = [];
|
|
19
|
+
const shutdowns = [];
|
|
20
|
+
const emitter = new EventEmitter();
|
|
21
|
+
const uninstall = installProcessGuards({
|
|
22
|
+
log: (message) => logs.push(message),
|
|
23
|
+
shutdown: async (reason) => {
|
|
24
|
+
shutdowns.push(reason);
|
|
25
|
+
},
|
|
26
|
+
exit: (code) => exits.push(code),
|
|
27
|
+
emitter: emitter,
|
|
28
|
+
});
|
|
29
|
+
return { logs, exits, shutdowns, emitter, uninstall };
|
|
30
|
+
};
|
|
31
|
+
/** The handlers finish their work in a `.finally`, so yield the microtask queue. */
|
|
32
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
33
|
+
describe('Unit: installProcessGuards — unhandled rejections are survivable', () => {
|
|
34
|
+
it('logs an Error rejection WITH its stack and does not exit', async () => {
|
|
35
|
+
const { logs, exits, shutdowns, emitter } = harness();
|
|
36
|
+
const error = new Error('background task failed');
|
|
37
|
+
emitter.emit('unhandledRejection', error);
|
|
38
|
+
await settle();
|
|
39
|
+
expect(logs).toHaveLength(1);
|
|
40
|
+
// The stack is what makes this diagnosable at all — the whole point of not
|
|
41
|
+
// just dying silently.
|
|
42
|
+
expect(logs[0]).toContain(error.stack);
|
|
43
|
+
expect(logs[0]).toContain('server continues');
|
|
44
|
+
// Survivable: no teardown, no exit.
|
|
45
|
+
expect(exits).toEqual([]);
|
|
46
|
+
expect(shutdowns).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it('reports EVERY rejection, not just the first', async () => {
|
|
49
|
+
// Registered with `on`, not `once`: a `once` here would silence every
|
|
50
|
+
// rejection after the first, which is the opposite of the intent.
|
|
51
|
+
const { logs, emitter } = harness();
|
|
52
|
+
emitter.emit('unhandledRejection', new Error('first'));
|
|
53
|
+
emitter.emit('unhandledRejection', new Error('second'));
|
|
54
|
+
emitter.emit('unhandledRejection', new Error('third'));
|
|
55
|
+
await settle();
|
|
56
|
+
expect(logs).toHaveLength(3);
|
|
57
|
+
});
|
|
58
|
+
it.each([
|
|
59
|
+
['a string', 'plain string reason', 'plain string reason'],
|
|
60
|
+
['undefined', undefined, 'undefined'],
|
|
61
|
+
['null', null, 'null'],
|
|
62
|
+
['a number', 42, '42'],
|
|
63
|
+
['a plain object', { code: 'E' }, '[object Object]'],
|
|
64
|
+
])('survives a non-Error rejection value: %s', async (_label, reason, rendered) => {
|
|
65
|
+
// `Promise.reject('nope')` and `Promise.reject()` are both legal, so the guard
|
|
66
|
+
// must never assume `.stack`. A guard that throws while reporting a failure is
|
|
67
|
+
// worse than no guard.
|
|
68
|
+
const { logs, exits, emitter } = harness();
|
|
69
|
+
expect(() => emitter.emit('unhandledRejection', reason)).not.toThrow();
|
|
70
|
+
await settle();
|
|
71
|
+
expect(logs).toHaveLength(1);
|
|
72
|
+
expect(logs[0]).toContain(rendered);
|
|
73
|
+
expect(exits).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
it('survives a Symbol reason, which `String()` refuses to render', async () => {
|
|
76
|
+
// `String(Symbol())` THROWS. The renderer falls back rather than letting the
|
|
77
|
+
// guard become the crash.
|
|
78
|
+
const { logs, exits, emitter } = harness();
|
|
79
|
+
expect(() => emitter.emit('unhandledRejection', Symbol('nope'))).not.toThrow();
|
|
80
|
+
await settle();
|
|
81
|
+
expect(logs).toHaveLength(1);
|
|
82
|
+
expect(exits).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('Unit: installProcessGuards — an uncaught exception is fatal', () => {
|
|
86
|
+
it('logs, shuts down gracefully, and exits NON-ZERO', async () => {
|
|
87
|
+
const { logs, exits, shutdowns, emitter } = harness();
|
|
88
|
+
const error = new Error('invariant broken');
|
|
89
|
+
emitter.emit('uncaughtException', error);
|
|
90
|
+
await settle();
|
|
91
|
+
expect(logs).toEqual([`uncaught exception, shutting down: ${error.stack}`]);
|
|
92
|
+
// Through the ordinary teardown, so a graph build in flight is still reaped.
|
|
93
|
+
expect(shutdowns).toEqual(['uncaught exception']);
|
|
94
|
+
// Non-zero: a supervisor that died must not look like a clean exit to whatever
|
|
95
|
+
// supervises IT.
|
|
96
|
+
expect(exits).toEqual([1]);
|
|
97
|
+
});
|
|
98
|
+
it('still exits when shutdown itself fails', async () => {
|
|
99
|
+
// The process state has already been declared unsound; a failing teardown must
|
|
100
|
+
// not wedge it open.
|
|
101
|
+
const logs = [];
|
|
102
|
+
const exits = [];
|
|
103
|
+
const emitter = new EventEmitter();
|
|
104
|
+
installProcessGuards({
|
|
105
|
+
log: (m) => logs.push(m),
|
|
106
|
+
shutdown: async () => {
|
|
107
|
+
throw new Error('teardown exploded');
|
|
108
|
+
},
|
|
109
|
+
exit: (code) => exits.push(code),
|
|
110
|
+
emitter: emitter,
|
|
111
|
+
});
|
|
112
|
+
emitter.emit('uncaughtException', new Error('boom'));
|
|
113
|
+
await settle();
|
|
114
|
+
expect(exits).toEqual([1]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('Unit: installProcessGuards — termination signals', () => {
|
|
118
|
+
it.each(['SIGINT', 'SIGTERM'])('%s shuts down and exits ZERO', async (signal) => {
|
|
119
|
+
const { exits, shutdowns, emitter } = harness();
|
|
120
|
+
emitter.emit(signal);
|
|
121
|
+
await settle();
|
|
122
|
+
expect(shutdowns).toEqual([signal]);
|
|
123
|
+
// Zero: an operator asking us to stop is not a failure.
|
|
124
|
+
expect(exits).toEqual([0]);
|
|
125
|
+
});
|
|
126
|
+
it('handles a signal only once, so a second SIGINT does not re-enter teardown', async () => {
|
|
127
|
+
const { shutdowns, emitter } = harness();
|
|
128
|
+
emitter.emit('SIGINT');
|
|
129
|
+
emitter.emit('SIGINT');
|
|
130
|
+
await settle();
|
|
131
|
+
expect(shutdowns).toEqual(['SIGINT']);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('Unit: installProcessGuards — registration hygiene', () => {
|
|
135
|
+
it('registers exactly one listener per event', () => {
|
|
136
|
+
const emitter = new EventEmitter();
|
|
137
|
+
installProcessGuards({
|
|
138
|
+
log: () => { },
|
|
139
|
+
shutdown: async () => { },
|
|
140
|
+
exit: () => { },
|
|
141
|
+
emitter: emitter,
|
|
142
|
+
});
|
|
143
|
+
expect(['SIGINT', 'SIGTERM', 'unhandledRejection', 'uncaughtException'].map((event) => emitter.listenerCount(event))).toEqual([1, 1, 1, 1]);
|
|
144
|
+
});
|
|
145
|
+
it('uninstall removes every listener, so start/stop/start does not accumulate', () => {
|
|
146
|
+
const emitter = new EventEmitter();
|
|
147
|
+
const install = () => installProcessGuards({
|
|
148
|
+
log: () => { },
|
|
149
|
+
shutdown: async () => { },
|
|
150
|
+
exit: () => { },
|
|
151
|
+
emitter: emitter,
|
|
152
|
+
});
|
|
153
|
+
const first = install();
|
|
154
|
+
first();
|
|
155
|
+
// Second lifecycle: counts must be back to one each, not two.
|
|
156
|
+
const second = install();
|
|
157
|
+
expect(['SIGINT', 'SIGTERM', 'unhandledRejection', 'uncaughtException'].map((event) => emitter.listenerCount(event))).toEqual([1, 1, 1, 1]);
|
|
158
|
+
second();
|
|
159
|
+
expect(['SIGINT', 'SIGTERM', 'unhandledRejection', 'uncaughtException'].map((event) => emitter.listenerCount(event))).toEqual([0, 0, 0, 0]);
|
|
160
|
+
});
|
|
161
|
+
it('uninstall is idempotent', () => {
|
|
162
|
+
const emitter = new EventEmitter();
|
|
163
|
+
const uninstall = installProcessGuards({
|
|
164
|
+
log: () => { },
|
|
165
|
+
shutdown: async () => { },
|
|
166
|
+
exit: () => { },
|
|
167
|
+
emitter: emitter,
|
|
168
|
+
});
|
|
169
|
+
uninstall();
|
|
170
|
+
expect(() => uninstall()).not.toThrow();
|
|
171
|
+
expect(emitter.listenerCount('unhandledRejection')).toEqual(0);
|
|
172
|
+
});
|
|
173
|
+
it('an uninstalled guard no longer reacts', async () => {
|
|
174
|
+
const { logs, exits, emitter, uninstall } = harness();
|
|
175
|
+
uninstall();
|
|
176
|
+
emitter.emit('unhandledRejection', new Error('ignored'));
|
|
177
|
+
emitter.emit('uncaughtException', new Error('ignored'));
|
|
178
|
+
await settle();
|
|
179
|
+
expect([logs, exits]).toEqual([[], []]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe('Unit: installProcessGuards — stdout is never touched', () => {
|
|
183
|
+
it('logs only through the injected logger, never to the JSON-RPC stream', async () => {
|
|
184
|
+
// stdout belongs to MCP JSON-RPC. A stray write there corrupts the protocol,
|
|
185
|
+
// which is a WORSE failure than the crash being handled.
|
|
186
|
+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
187
|
+
try {
|
|
188
|
+
const { logs, emitter } = harness();
|
|
189
|
+
emitter.emit('unhandledRejection', new Error('noisy'));
|
|
190
|
+
emitter.emit('uncaughtException', new Error('fatal'));
|
|
191
|
+
await settle();
|
|
192
|
+
expect(stdoutWrite).not.toHaveBeenCalled();
|
|
193
|
+
expect(logs).toHaveLength(2);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
stdoutWrite.mockRestore();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
//# sourceMappingURL=process-guards.spec.js.map
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* user-facing surface.
|
|
8
8
|
*/
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { type SupervisorContext } from '../context.js';
|
|
10
11
|
import { type Logger } from '../logger.js';
|
|
11
|
-
import { type SupervisorContext } from './validate-code.js';
|
|
12
12
|
export interface ServerOptions {
|
|
13
13
|
/** Absolute project root that buffers are validated against. */
|
|
14
14
|
projectDir: string;
|
|
@@ -16,6 +16,11 @@ export interface ServerOptions {
|
|
|
16
16
|
log?: Logger;
|
|
17
17
|
/** Advertised server version. */
|
|
18
18
|
version?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Whether cross-file impact runs. Defaults to ON — only an explicit `false`, from
|
|
21
|
+
* `--no-impact` or `POS_SUPERVISOR_NO_IMPACT`, turns it off.
|
|
22
|
+
*/
|
|
23
|
+
impactEnabled?: boolean;
|
|
19
24
|
}
|
|
20
25
|
export interface ServerHandle {
|
|
21
26
|
server: McpServer;
|
package/dist/transport/server.js
CHANGED
|
@@ -9,13 +9,25 @@
|
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { createLogger } from '../logger.js';
|
|
12
|
+
import { installProcessGuards } from './process-guards.js';
|
|
13
|
+
import { SERVER_INSTRUCTIONS } from './instructions.js';
|
|
12
14
|
import { registerValidateCode } from './validate-code.js';
|
|
13
15
|
const SERVER_NAME = 'platformos-mcp-supervisor';
|
|
14
16
|
const DEFAULT_VERSION = '0.0.1';
|
|
15
17
|
export async function startServer(opts) {
|
|
16
18
|
const log = opts.log ?? createLogger(SERVER_NAME);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// NOTHING IS BUILT OR WARMED HERE, by design. check-node owns one lazy `App` per project
|
|
20
|
+
// at process level and reconciles it per call, and impact is derived per request from
|
|
21
|
+
// the project's text (`impact/project-scan.ts`) — so there is no graph to build at boot,
|
|
22
|
+
// nothing to keep fresh, and no "still computing" answer.
|
|
23
|
+
const context = {
|
|
24
|
+
projectDir: opts.projectDir,
|
|
25
|
+
log,
|
|
26
|
+
impactEnabled: opts.impactEnabled,
|
|
27
|
+
};
|
|
28
|
+
// `instructions` reaches the model with the tool list; without them an agent has only the
|
|
29
|
+
// tool description to go on and invents a reading of the result (see instructions.ts).
|
|
30
|
+
const server = new McpServer({ name: SERVER_NAME, version: opts.version ?? DEFAULT_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
19
31
|
registerValidateCode(server, context);
|
|
20
32
|
const transport = new StdioServerTransport();
|
|
21
33
|
await server.connect(transport);
|
|
@@ -27,16 +39,15 @@ export async function startServer(opts) {
|
|
|
27
39
|
closed = true;
|
|
28
40
|
if (reason)
|
|
29
41
|
log(`shutting down (${reason})`);
|
|
42
|
+
// Drop the process listeners as part of teardown, so a start/stop/start cycle
|
|
43
|
+
// in one process does not accumulate them.
|
|
44
|
+
uninstallGuards();
|
|
30
45
|
await server.close();
|
|
31
46
|
};
|
|
32
|
-
|
|
47
|
+
// Signals plus the last-resort error guards: without them a single unhandled
|
|
48
|
+
// rejection on a background path is fatal under Node's default, and the agent
|
|
49
|
+
// loses the tool mid-session with nothing in the JSON-RPC stream to explain it.
|
|
50
|
+
const uninstallGuards = installProcessGuards({ log, shutdown });
|
|
33
51
|
return { server, context, shutdown };
|
|
34
52
|
}
|
|
35
|
-
function installSignalHandlers(shutdown) {
|
|
36
|
-
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
37
|
-
process.once(signal, () => {
|
|
38
|
-
void shutdown(signal).finally(() => process.exit(0));
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
53
|
//# sourceMappingURL=server.js.map
|
|
@@ -1,26 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Registration of the `validate_code` MCP tool.
|
|
2
|
+
* Registration of the `validate_code` MCP tool — the supervisor's entire surface.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* ONE TOOL, TWO INPUT SHAPES. Validating one file and validating a coordinated multi-file
|
|
5
|
+
* change are the same operation on a different number of buffers, so they are one tool:
|
|
6
|
+
*
|
|
7
|
+
* validate_code({ file_path, content }) -> a single result
|
|
8
|
+
* validate_code({ files: [...] }) -> one result per file + a batch gate
|
|
9
|
+
*
|
|
10
|
+
* There is no `many` flag — which form was sent is evident from the arguments.
|
|
11
|
+
*
|
|
12
|
+
* PASSING SEVERAL FILES IS MORE ACCURATE, not just faster: with every buffer overlaid at
|
|
13
|
+
* once a partial created alongside its caller resolves, where one at a time it is reported
|
|
14
|
+
* missing. The description says so, because that is why an agent should reach for it.
|
|
15
|
+
*
|
|
16
|
+
* This module owns only the MCP surface: schema, description, registration, and mapping
|
|
17
|
+
* input shape to output shape. All logic lives in `validate/`.
|
|
8
18
|
*/
|
|
9
19
|
import { type ZodRawShape } from 'zod';
|
|
10
20
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
-
import type {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
projectDir: string;
|
|
16
|
-
log: Logger;
|
|
17
|
-
}
|
|
21
|
+
import type { SupervisorContext } from '../context.js';
|
|
22
|
+
import type { ValidateCodeParams, ValidateCodeResult, ValidateFilesResult } from '../result/types.js';
|
|
23
|
+
import { type ValidateAdapters } from '../validate/validate-buffers.js';
|
|
24
|
+
import { MAX_BATCH_BYTES, MAX_BATCH_FILES } from '../validate/batch-bounds.js';
|
|
18
25
|
/**
|
|
19
26
|
* zod raw shape for the tool input (validated by the MCP SDK before dispatch).
|
|
20
27
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
28
|
+
* Both forms are optional at the schema level and the handler requires exactly one,
|
|
29
|
+
* because a zod union of two object shapes defeats the SDK's JSON-Schema conversion
|
|
30
|
+
* — it emits `anyOf`, which several MCP clients do not surface to the model at all.
|
|
31
|
+
* Two optional fields with a documented rule converts cleanly and reads clearly.
|
|
32
|
+
*
|
|
33
|
+
* WHAT THE MODEL SEES IS THE CONVERSION, NOT THIS. Everything an agent knows about
|
|
34
|
+
* the arguments arrives as the JSON Schema this shape converts to, so a constraint
|
|
35
|
+
* that does not convert may as well not be advertised at all. Prefer a zod builtin
|
|
36
|
+
* over a `.refine` wherever one expresses the rule; `transport/validate-code.spec.ts`
|
|
37
|
+
* pins the emitted document end to end.
|
|
38
|
+
*
|
|
39
|
+
* Typed as `ZodRawShape` (not the inferred literal shape) so `registerTool` does not
|
|
40
|
+
* instantiate excessively deeply over the schema (TS2589 under zod 3.25).
|
|
24
41
|
*/
|
|
25
42
|
export declare const VALIDATE_CODE_INPUT: ZodRawShape;
|
|
43
|
+
/**
|
|
44
|
+
* The agent-facing prose, exported so tests can pin claims that must not rot. The
|
|
45
|
+
* description is the agent's whole understanding of this tool; a stale sentence
|
|
46
|
+
* here misleads silently.
|
|
47
|
+
*/
|
|
48
|
+
export declare const TOOL_TEXT: {
|
|
49
|
+
readonly VALIDATE_CODE_DESCRIPTION: string;
|
|
50
|
+
};
|
|
26
51
|
export declare function registerValidateCode(server: McpServer, ctx: SupervisorContext): void;
|
|
52
|
+
/**
|
|
53
|
+
* The tool handler: normalize the input shape, validate, and shape the response to
|
|
54
|
+
* match what was asked.
|
|
55
|
+
*
|
|
56
|
+
* Returns the flat single-file result for the single form and the per-file envelope
|
|
57
|
+
* for the batch form — the response mirrors the request, so a caller never parses a
|
|
58
|
+
* shape it did not ask for.
|
|
59
|
+
*/
|
|
60
|
+
export declare function runValidateCode(ctx: SupervisorContext, params: ValidateCodeParams, overrides?: Partial<ValidateAdapters>): Promise<ValidateCodeResult | ValidateFilesResult>;
|
|
61
|
+
export { MAX_BATCH_BYTES, MAX_BATCH_FILES };
|