opencode-muse 0.1.2 → 0.2.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/README.md +5 -4
- package/dist/msp/host.d.ts +3 -1
- package/dist/msp/host.js +22 -6
- package/dist/msp/schema-diff.d.ts +73 -0
- package/dist/msp/schema-diff.js +339 -0
- package/dist/provider.d.ts +4 -0
- package/dist/provider.js +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Add the published package to your project-level `opencode.jsonc` (or
|
|
|
16
16
|
```jsonc
|
|
17
17
|
{
|
|
18
18
|
"$schema": "https://opencode.ai/config.json",
|
|
19
|
-
"plugins": ["opencode-muse@0.1
|
|
19
|
+
"plugins": ["opencode-muse@0.2.1"],
|
|
20
20
|
}
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -96,9 +96,10 @@ setup placeholder rather than advertising a model that will fail on use.
|
|
|
96
96
|
been tested, but recovery from a real Muse event gap has only been tested
|
|
97
97
|
with a simulated gap. See
|
|
98
98
|
[#23](https://github.com/pgquiles/opencode-muse/issues/23).
|
|
99
|
-
- **Muse CLI compatibility:** The plugin is pinned
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
- **Muse CLI compatibility:** The plugin is pinned to SDK 1.4.12 and speaks
|
|
100
|
+
envelope schema version 1. A newer CLI fingerprint starts with a recorded
|
|
101
|
+
warning instead of failing closed; a missing/malformed fingerprint or an
|
|
102
|
+
unsupported envelope version still fails closed. See
|
|
102
103
|
[#21](https://github.com/pgquiles/opencode-muse/issues/21).
|
|
103
104
|
- **Billing and simultaneous work:** Pay-as-you-go behavior has not yet been
|
|
104
105
|
tested. The plugin also allows only one active writer per workspace, so a
|
package/dist/msp/host.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type MspHandshake, type SpawnMspConnectionOptions } from "@muse-code/sdk";
|
|
1
|
+
import { type FingerprintWarning, type MspHandshake, type SpawnMspConnectionOptions } from "@muse-code/sdk";
|
|
2
2
|
import { MspClient, type MspConnectionLike, type MspNotification } from "./client.js";
|
|
3
3
|
export interface MspHostRuntime {
|
|
4
4
|
connection: MspConnectionLike;
|
|
@@ -39,6 +39,8 @@ export declare class MspHost {
|
|
|
39
39
|
private readonly runtime;
|
|
40
40
|
readonly client: MspClient;
|
|
41
41
|
readonly initializeResult: Record<string, unknown>;
|
|
42
|
+
/** Set when the served schema fingerprint differs from the SDK pin. Warns, never blocks. */
|
|
43
|
+
readonly fingerprintWarning?: FingerprintWarning;
|
|
42
44
|
readonly exited: Promise<MspHostExit>;
|
|
43
45
|
private state;
|
|
44
46
|
private closePromise;
|
package/dist/msp/host.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MspError, EXPECTED_SCHEMA_FINGERPRINT, MuseHostStartError as SdkHostStartError, spawnMspConnection, } from "@muse-code/sdk";
|
|
1
|
+
import { MspError, checkServedFingerprint, EXPECTED_SCHEMA_FINGERPRINT, MuseHostStartError as SdkHostStartError, spawnMspConnection, } from "@muse-code/sdk";
|
|
2
2
|
import { MspCommandTimeoutError, MspHostClosedError, MspHostStartError, MspInitializeRejectedError, MspSchemaMismatchError, MspStartupTimeoutError, MspTransportError, } from "../errors.js";
|
|
3
3
|
import { MspClient } from "./client.js";
|
|
4
4
|
function defaultHandshake(options) {
|
|
@@ -32,6 +32,17 @@ function sanitizeExit(value) {
|
|
|
32
32
|
// Deliberately omit stderrTail, raw messages, and arbitrary child properties.
|
|
33
33
|
return safe;
|
|
34
34
|
}
|
|
35
|
+
const SCHEMA_FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
36
|
+
/** Envelope schema versions this plugin speaks. The SDK documents version 1 as long-lived. */
|
|
37
|
+
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
38
|
+
function readSchemaInfo(value) {
|
|
39
|
+
if (typeof value !== "object" || value === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
const record = value;
|
|
42
|
+
if (typeof record.fingerprint !== "string")
|
|
43
|
+
return undefined;
|
|
44
|
+
return { fingerprint: record.fingerprint, version: record.version };
|
|
45
|
+
}
|
|
35
46
|
function withTimeout(promise, timeoutMs) {
|
|
36
47
|
let timer;
|
|
37
48
|
return Promise.race([
|
|
@@ -57,12 +68,15 @@ export class MspHost {
|
|
|
57
68
|
runtime;
|
|
58
69
|
client;
|
|
59
70
|
initializeResult;
|
|
71
|
+
/** Set when the served schema fingerprint differs from the SDK pin. Warns, never blocks. */
|
|
72
|
+
fingerprintWarning;
|
|
60
73
|
exited;
|
|
61
74
|
state = "open";
|
|
62
75
|
closePromise;
|
|
63
|
-
constructor(runtime, options) {
|
|
76
|
+
constructor(runtime, options, fingerprintWarning) {
|
|
64
77
|
this.runtime = runtime;
|
|
65
78
|
this.initializeResult = runtime.initializeResult;
|
|
79
|
+
this.fingerprintWarning = fingerprintWarning;
|
|
66
80
|
this.client = new MspClient(runtime.connection, {
|
|
67
81
|
commandTimeoutMs: options.commandTimeoutMs,
|
|
68
82
|
requestTimeoutMs: options.requestTimeoutMs,
|
|
@@ -107,13 +121,15 @@ export class MspHost {
|
|
|
107
121
|
const initializing = handshake.initialize(initializeParams);
|
|
108
122
|
try {
|
|
109
123
|
const runtime = await withTimeout(initializing, timeoutMs);
|
|
110
|
-
const schema = runtime.initializeResult.schema;
|
|
111
|
-
const servedFingerprint =
|
|
112
|
-
if (
|
|
124
|
+
const schema = readSchemaInfo(runtime.initializeResult.schema);
|
|
125
|
+
const servedFingerprint = schema?.fingerprint ?? "missing";
|
|
126
|
+
if (schema === undefined ||
|
|
127
|
+
!SCHEMA_FINGERPRINT_PATTERN.test(schema.fingerprint) ||
|
|
128
|
+
(schema.version !== undefined && schema.version !== SUPPORTED_SCHEMA_VERSION)) {
|
|
113
129
|
await closeQuietly(runtime.close.bind(runtime));
|
|
114
130
|
throw new MspSchemaMismatchError(EXPECTED_SCHEMA_FINGERPRINT, servedFingerprint);
|
|
115
131
|
}
|
|
116
|
-
return new MspHost(runtime, options);
|
|
132
|
+
return new MspHost(runtime, options, checkServedFingerprint(schema.fingerprint));
|
|
117
133
|
}
|
|
118
134
|
catch (error) {
|
|
119
135
|
if (error instanceof MspSchemaMismatchError)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline schema-evolution check for policy #21.
|
|
3
|
+
*
|
|
4
|
+
* Compares MSP stable-schema snapshots and classifies the delta on the
|
|
5
|
+
* protocol surfaces this plugin actually uses. Additive growth passes with
|
|
6
|
+
* notes; removals, new required fields, dropped properties, or envelope
|
|
7
|
+
* version bumps on the curated surface fail closed for human review.
|
|
8
|
+
*
|
|
9
|
+
* Failing closed on uncertainty is deliberate: a false positive costs a
|
|
10
|
+
* human classification pass, while a false negative ships against a
|
|
11
|
+
* contract the bridge was never tested with.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SCHEMA_SECTIONS: readonly ["methods", "requests", "notifications", "errors", "capabilities"];
|
|
14
|
+
export type SchemaSection = (typeof SCHEMA_SECTIONS)[number];
|
|
15
|
+
/** Methods the bridge sends. Each name verified at a call site in src (manager, provider, language-model). */
|
|
16
|
+
export declare const CURATED_METHODS: readonly string[];
|
|
17
|
+
/** Server-to-client requests the bridge explicitly fails closed on (no fabricated answers). */
|
|
18
|
+
export declare const CURATED_REQUESTS: readonly string[];
|
|
19
|
+
/** Notifications the bridge consumes in src/model/stream.ts and src/model/language-model.ts. */
|
|
20
|
+
export declare const CURATED_NOTIFICATIONS: readonly string[];
|
|
21
|
+
export interface SchemaExportManifest {
|
|
22
|
+
fingerprint: string;
|
|
23
|
+
schemaVersion: number;
|
|
24
|
+
experimental: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface SchemaExport {
|
|
27
|
+
manifest: SchemaExportManifest;
|
|
28
|
+
schema: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface DefRecord {
|
|
31
|
+
digest: string;
|
|
32
|
+
required: string[] | null;
|
|
33
|
+
props: string[] | null;
|
|
34
|
+
}
|
|
35
|
+
export interface SchemaSnapshot {
|
|
36
|
+
cli_version: string;
|
|
37
|
+
fingerprint: string;
|
|
38
|
+
schema_version: number | null;
|
|
39
|
+
experimental?: boolean;
|
|
40
|
+
provenance?: string;
|
|
41
|
+
surface: Record<SchemaSection, string[] | null>;
|
|
42
|
+
member_digests?: Record<string, Record<string, string>>;
|
|
43
|
+
defs?: Record<string, DefRecord>;
|
|
44
|
+
/** Legacy v1 field: whole-section $defs digest only. Superseded by defs. */
|
|
45
|
+
defs_digests?: Record<string, string>;
|
|
46
|
+
sections_digest: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
export type FindingSeverity = "breaking" | "note";
|
|
49
|
+
export interface DiffFinding {
|
|
50
|
+
severity: FindingSeverity;
|
|
51
|
+
kind: string;
|
|
52
|
+
detail: string;
|
|
53
|
+
}
|
|
54
|
+
export interface Classification {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
findings: DiffFinding[];
|
|
57
|
+
}
|
|
58
|
+
/** Builds the ledger snapshot for one offline CLI schema export. */
|
|
59
|
+
export declare function snapshotExport(cliVersion: string, provenance: string, exp: SchemaExport): SchemaSnapshot;
|
|
60
|
+
/** Collects `#/$defs/<Name>` targets reachable from roots over new-side bodies. */
|
|
61
|
+
export declare function collectRefs(roots: Record<string, unknown>, defs: Record<string, unknown>): Set<string>;
|
|
62
|
+
export interface ClassifyOptions {
|
|
63
|
+
/**
|
|
64
|
+
* New-side bodies for reachability attribution: { members: name->body, defs: name->body }.
|
|
65
|
+
* When absent (snapshot-vs-snapshot), changed/removed defs fall back to conservative rules.
|
|
66
|
+
*/
|
|
67
|
+
newBodies?: {
|
|
68
|
+
members: Record<string, unknown>;
|
|
69
|
+
defs: Record<string, unknown>;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Classifies old-vs-new snapshots. Breaking findings fail closed for human review. */
|
|
73
|
+
export declare function classifySnapshots(oldSnap: SchemaSnapshot, newSnap: SchemaSnapshot, options?: ClassifyOptions): Classification;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Offline schema-evolution check for policy #21.
|
|
4
|
+
*
|
|
5
|
+
* Compares MSP stable-schema snapshots and classifies the delta on the
|
|
6
|
+
* protocol surfaces this plugin actually uses. Additive growth passes with
|
|
7
|
+
* notes; removals, new required fields, dropped properties, or envelope
|
|
8
|
+
* version bumps on the curated surface fail closed for human review.
|
|
9
|
+
*
|
|
10
|
+
* Failing closed on uncertainty is deliberate: a false positive costs a
|
|
11
|
+
* human classification pass, while a false negative ships against a
|
|
12
|
+
* contract the bridge was never tested with.
|
|
13
|
+
*/
|
|
14
|
+
export const SCHEMA_SECTIONS = ["methods", "requests", "notifications", "errors", "capabilities"];
|
|
15
|
+
/** Methods the bridge sends. Each name verified at a call site in src (manager, provider, language-model). */
|
|
16
|
+
export const CURATED_METHODS = [
|
|
17
|
+
"model/list", // src/provider.ts catalog fetch
|
|
18
|
+
"session/start", // src/session/manager.ts resolveSession
|
|
19
|
+
"session/resume", // src/session/manager.ts restart recovery
|
|
20
|
+
"session/setModel", // src/session/manager.ts + src/model/language-model.ts selection routing
|
|
21
|
+
"turn/start", // src/model/language-model.ts agent turns
|
|
22
|
+
"turn/interrupt", // src/model/language-model.ts abort path
|
|
23
|
+
"turn/unqueue", // src/model/language-model.ts abort path
|
|
24
|
+
"media/upload", // src/model/language-model.ts video uploads
|
|
25
|
+
"view/subscribe", // src/msp/host.ts cursor replay via MspClient
|
|
26
|
+
"view/page", // src/model/language-model.ts view/gap recovery
|
|
27
|
+
];
|
|
28
|
+
/** Server-to-client requests the bridge explicitly fails closed on (no fabricated answers). */
|
|
29
|
+
export const CURATED_REQUESTS = [
|
|
30
|
+
"approval/request", // src/msp/host.ts answers method-not-found
|
|
31
|
+
"userInput/request", // src/msp/host.ts answers method-not-found
|
|
32
|
+
];
|
|
33
|
+
/** Notifications the bridge consumes in src/model/stream.ts and src/model/language-model.ts. */
|
|
34
|
+
export const CURATED_NOTIFICATIONS = [
|
|
35
|
+
"item/started",
|
|
36
|
+
"item/delta",
|
|
37
|
+
"item/completed",
|
|
38
|
+
"turn/completed",
|
|
39
|
+
"turn/foregroundCompleted",
|
|
40
|
+
"turn/retracted",
|
|
41
|
+
"turn/unqueued",
|
|
42
|
+
"session/tokenUsage",
|
|
43
|
+
"view/gap",
|
|
44
|
+
];
|
|
45
|
+
function sortKeys(value) {
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return value.map(sortKeys);
|
|
48
|
+
if (value !== null && typeof value === "object") {
|
|
49
|
+
return Object.fromEntries(Object.entries(value)
|
|
50
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
51
|
+
.map(([k, v]) => [k, sortKeys(v)]));
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function sha256Hex(canonicalJson) {
|
|
56
|
+
return createHash("sha256").update(canonicalJson, "utf8").digest("hex");
|
|
57
|
+
}
|
|
58
|
+
function canonical(value) {
|
|
59
|
+
return JSON.stringify(sortKeys(value));
|
|
60
|
+
}
|
|
61
|
+
function stringArray(value) {
|
|
62
|
+
if (!Array.isArray(value))
|
|
63
|
+
return null;
|
|
64
|
+
if (!value.every((v) => typeof v === "string"))
|
|
65
|
+
return null;
|
|
66
|
+
return [...value].sort();
|
|
67
|
+
}
|
|
68
|
+
/** Stable name for one section member, or null when it cannot be named. */
|
|
69
|
+
function memberName(section, entry) {
|
|
70
|
+
if (typeof entry === "string")
|
|
71
|
+
return entry;
|
|
72
|
+
if (entry !== null && typeof entry === "object") {
|
|
73
|
+
const record = entry;
|
|
74
|
+
if (section === "errors" && typeof record.kind === "string")
|
|
75
|
+
return record.kind;
|
|
76
|
+
if (typeof record.name === "string")
|
|
77
|
+
return record.name;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function sectionEntries(section, schema) {
|
|
82
|
+
const raw = schema[section];
|
|
83
|
+
const entries = [];
|
|
84
|
+
if (Array.isArray(raw)) {
|
|
85
|
+
for (const entry of raw) {
|
|
86
|
+
const name = memberName(section, entry);
|
|
87
|
+
if (name === null)
|
|
88
|
+
return null;
|
|
89
|
+
entries.push({ name, digest: sha256Hex(canonical(entry)) });
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
if (raw !== null && typeof raw === "object") {
|
|
94
|
+
if (section === "capabilities") {
|
|
95
|
+
const record = raw;
|
|
96
|
+
const grantable = record.grantable;
|
|
97
|
+
if (!Array.isArray(grantable))
|
|
98
|
+
return null;
|
|
99
|
+
for (const entry of grantable) {
|
|
100
|
+
const name = memberName(section, entry);
|
|
101
|
+
if (name === null)
|
|
102
|
+
return null;
|
|
103
|
+
entries.push({ name, digest: sha256Hex(canonical(entry)) });
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
for (const [name, body] of Object.entries(raw)) {
|
|
108
|
+
entries.push({ name, digest: sha256Hex(canonical(body)) });
|
|
109
|
+
}
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
function defRecord(body) {
|
|
115
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
116
|
+
return null;
|
|
117
|
+
const record = body;
|
|
118
|
+
const properties = record.properties;
|
|
119
|
+
return {
|
|
120
|
+
digest: sha256Hex(canonical(body)),
|
|
121
|
+
required: stringArray(record.required),
|
|
122
|
+
props: properties !== null && typeof properties === "object" && !Array.isArray(properties)
|
|
123
|
+
? Object.keys(properties).sort()
|
|
124
|
+
: null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Builds the ledger snapshot for one offline CLI schema export. */
|
|
128
|
+
export function snapshotExport(cliVersion, provenance, exp) {
|
|
129
|
+
if (typeof exp?.manifest?.fingerprint !== "string" || typeof exp?.manifest?.schemaVersion !== "number") {
|
|
130
|
+
throw new Error("schema-diff: export manifest must carry a fingerprint string and numeric schemaVersion");
|
|
131
|
+
}
|
|
132
|
+
if (exp.schema === null || typeof exp.schema !== "object" || Array.isArray(exp.schema)) {
|
|
133
|
+
throw new Error("schema-diff: export schema must be an object");
|
|
134
|
+
}
|
|
135
|
+
const surface = {};
|
|
136
|
+
const member_digests = {};
|
|
137
|
+
const sections_digest = {};
|
|
138
|
+
for (const section of SCHEMA_SECTIONS) {
|
|
139
|
+
const entries = sectionEntries(section, exp.schema);
|
|
140
|
+
surface[section] = entries === null ? null : entries.map((e) => e.name).sort();
|
|
141
|
+
if (entries !== null && (section === "methods" || section === "requests" || section === "notifications")) {
|
|
142
|
+
member_digests[section] = Object.fromEntries(entries.map((e) => [e.name, e.digest]));
|
|
143
|
+
}
|
|
144
|
+
sections_digest[section] = sha256Hex(canonical(exp.schema[section] ?? null));
|
|
145
|
+
}
|
|
146
|
+
const rawDefs = exp.schema.$defs;
|
|
147
|
+
if (rawDefs === null || typeof rawDefs !== "object" || Array.isArray(rawDefs)) {
|
|
148
|
+
throw new Error("schema-diff: export schema must carry a $defs object");
|
|
149
|
+
}
|
|
150
|
+
const defs = {};
|
|
151
|
+
for (const [name, body] of Object.entries(rawDefs)) {
|
|
152
|
+
const record = defRecord(body);
|
|
153
|
+
if (record === null)
|
|
154
|
+
throw new Error(`schema-diff: $defs member is not an object: ${name}`);
|
|
155
|
+
defs[name] = record;
|
|
156
|
+
}
|
|
157
|
+
sections_digest.$defs = sha256Hex(canonical(exp.schema.$defs));
|
|
158
|
+
return {
|
|
159
|
+
cli_version: cliVersion,
|
|
160
|
+
fingerprint: exp.manifest.fingerprint,
|
|
161
|
+
schema_version: exp.manifest.schemaVersion,
|
|
162
|
+
experimental: exp.manifest.experimental,
|
|
163
|
+
provenance,
|
|
164
|
+
surface,
|
|
165
|
+
member_digests,
|
|
166
|
+
defs,
|
|
167
|
+
sections_digest,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** Collects `#/$defs/<Name>` targets reachable from roots over new-side bodies. */
|
|
171
|
+
export function collectRefs(roots, defs) {
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
const visit = (value) => {
|
|
174
|
+
if (typeof value === "string") {
|
|
175
|
+
const match = /^#\/\$defs\/([A-Za-z0-9_]+)$/.exec(value);
|
|
176
|
+
if (match && !seen.has(match[1])) {
|
|
177
|
+
seen.add(match[1]);
|
|
178
|
+
const body = defs[match[1]];
|
|
179
|
+
if (body !== undefined)
|
|
180
|
+
visit(body);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
for (const v of value)
|
|
186
|
+
visit(v);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (value !== null && typeof value === "object") {
|
|
190
|
+
for (const v of Object.values(value))
|
|
191
|
+
visit(v);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
for (const root of Object.values(roots))
|
|
195
|
+
visit(root);
|
|
196
|
+
return seen;
|
|
197
|
+
}
|
|
198
|
+
function curatedFor(section) {
|
|
199
|
+
if (section === "methods")
|
|
200
|
+
return new Set(CURATED_METHODS);
|
|
201
|
+
if (section === "requests")
|
|
202
|
+
return new Set(CURATED_REQUESTS);
|
|
203
|
+
if (section === "notifications")
|
|
204
|
+
return new Set(CURATED_NOTIFICATIONS);
|
|
205
|
+
return new Set();
|
|
206
|
+
}
|
|
207
|
+
function requireSurface(snap, which) {
|
|
208
|
+
if (snap === null || typeof snap !== "object")
|
|
209
|
+
throw new Error(`schema-diff: ${which} snapshot is not an object`);
|
|
210
|
+
const surface = snap.surface;
|
|
211
|
+
if (surface === null || typeof surface !== "object" || Array.isArray(surface)) {
|
|
212
|
+
throw new Error(`schema-diff: ${which} snapshot has no surface record`);
|
|
213
|
+
}
|
|
214
|
+
for (const section of SCHEMA_SECTIONS) {
|
|
215
|
+
const value = surface[section];
|
|
216
|
+
if (!Array.isArray(value) && value !== null) {
|
|
217
|
+
throw new Error(`schema-diff: ${which} snapshot surface.${section} is malformed`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Classifies old-vs-new snapshots. Breaking findings fail closed for human review. */
|
|
222
|
+
export function classifySnapshots(oldSnap, newSnap, options = {}) {
|
|
223
|
+
requireSurface(oldSnap, "old");
|
|
224
|
+
requireSurface(newSnap, "new");
|
|
225
|
+
const findings = [];
|
|
226
|
+
if (oldSnap.schema_version === null || oldSnap.schema_version === undefined ||
|
|
227
|
+
newSnap.schema_version === null || newSnap.schema_version === undefined) {
|
|
228
|
+
throw new Error("schema-diff: both snapshots must carry a schema_version");
|
|
229
|
+
}
|
|
230
|
+
if (oldSnap.schema_version !== newSnap.schema_version) {
|
|
231
|
+
findings.push({
|
|
232
|
+
severity: "breaking",
|
|
233
|
+
kind: "envelope-version-changed",
|
|
234
|
+
detail: `envelope schema version ${String(oldSnap.schema_version)} -> ${String(newSnap.schema_version)}`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const oldMembers = oldSnap.member_digests;
|
|
238
|
+
const newMembers = newSnap.member_digests;
|
|
239
|
+
const memberLevel = oldMembers !== undefined && oldMembers !== null && newMembers !== undefined && newMembers !== null;
|
|
240
|
+
if (!memberLevel) {
|
|
241
|
+
findings.push({
|
|
242
|
+
severity: "note",
|
|
243
|
+
kind: "member-level-unavailable",
|
|
244
|
+
detail: "member-level digests absent on one side; falling back to set-level comparison",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
for (const section of SCHEMA_SECTIONS) {
|
|
248
|
+
const oldNames = new Set(oldSnap.surface[section] ?? []);
|
|
249
|
+
const newNames = new Set(newSnap.surface[section] ?? []);
|
|
250
|
+
const curated = curatedFor(section);
|
|
251
|
+
for (const name of [...newNames].filter((n) => !oldNames.has(n)).sort()) {
|
|
252
|
+
findings.push({ severity: "note", kind: `${section}-added`, detail: `${section} added: ${name}` });
|
|
253
|
+
}
|
|
254
|
+
for (const name of [...oldNames].filter((n) => !newNames.has(n)).sort()) {
|
|
255
|
+
if (curated.has(name)) {
|
|
256
|
+
findings.push({ severity: "breaking", kind: `${section}-removed`, detail: `${section} removed curated member: ${name}` });
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
findings.push({ severity: "note", kind: `${section}-removed`, detail: `${section} removed non-curated member: ${name}` });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (memberLevel && (section === "methods" || section === "requests" || section === "notifications")) {
|
|
263
|
+
const oldMap = oldMembers?.[section] ?? {};
|
|
264
|
+
const newMap = newMembers?.[section] ?? {};
|
|
265
|
+
for (const name of [...oldNames].filter((n) => newNames.has(n)).sort()) {
|
|
266
|
+
if (oldMap[name] !== newMap[name]) {
|
|
267
|
+
if (curated.has(name)) {
|
|
268
|
+
findings.push({ severity: "breaking", kind: `${section}-changed`, detail: `${section} changed curated member shape: ${name}` });
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
findings.push({ severity: "note", kind: `${section}-changed`, detail: `${section} changed non-curated member shape: ${name}` });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Reachability over new-side bodies scopes nested def changes to what the bridge can observe.
|
|
278
|
+
let reachable = null;
|
|
279
|
+
if (options.newBodies) {
|
|
280
|
+
const roots = {};
|
|
281
|
+
for (const name of [...CURATED_METHODS, ...CURATED_REQUESTS, ...CURATED_NOTIFICATIONS]) {
|
|
282
|
+
if (options.newBodies.members[name] !== undefined)
|
|
283
|
+
roots[name] = options.newBodies.members[name];
|
|
284
|
+
}
|
|
285
|
+
reachable = collectRefs(roots, options.newBodies.defs);
|
|
286
|
+
}
|
|
287
|
+
const oldDefs = oldSnap.defs ?? {};
|
|
288
|
+
const newDefs = newSnap.defs ?? {};
|
|
289
|
+
const defsDetailed = oldSnap.defs !== undefined && newSnap.defs !== undefined;
|
|
290
|
+
if (!defsDetailed) {
|
|
291
|
+
findings.push({
|
|
292
|
+
severity: "note",
|
|
293
|
+
kind: "defs-unavailable",
|
|
294
|
+
detail: "per-def records absent on one side; nested changes covered only by member shapes where available",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const names = new Set([...Object.keys(oldDefs), ...Object.keys(newDefs)]);
|
|
299
|
+
for (const name of [...names].sort()) {
|
|
300
|
+
const oldDef = oldDefs[name];
|
|
301
|
+
const newDef = newDefs[name];
|
|
302
|
+
if (!oldDef) {
|
|
303
|
+
findings.push({ severity: "note", kind: "def-added", detail: `$defs added: ${name}` });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!newDef) {
|
|
307
|
+
if (reachable !== null && reachable.has(name)) {
|
|
308
|
+
findings.push({ severity: "breaking", kind: "def-removed", detail: `$defs removed reachable def: ${name}` });
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
findings.push({ severity: "note", kind: "def-removed", detail: `$defs removed: ${name}${reachable === null ? " (reachability unknown: snapshot-vs-snapshot)" : " (unreachable from curated surface)"}` });
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (oldDef.digest === newDef.digest)
|
|
316
|
+
continue;
|
|
317
|
+
const requiredAdded = (newDef.required ?? []).filter((r) => !(oldDef.required ?? []).includes(r));
|
|
318
|
+
const propsRemoved = (oldDef.props ?? []).filter((p) => !(newDef.props ?? []).includes(p));
|
|
319
|
+
const breakingShape = requiredAdded.length > 0 || propsRemoved.length > 0;
|
|
320
|
+
const isReachable = reachable === null ? null : reachable.has(name);
|
|
321
|
+
if (breakingShape && (isReachable === null || isReachable)) {
|
|
322
|
+
findings.push({
|
|
323
|
+
severity: "breaking",
|
|
324
|
+
kind: "def-changed",
|
|
325
|
+
detail: `$defs changed${isReachable === null ? " (reachability unknown: snapshot-vs-snapshot)" : " (reachable from curated surface)"}: ${name}` +
|
|
326
|
+
`${requiredAdded.length > 0 ? ` required+${requiredAdded.join(",")}` : ""}${propsRemoved.length > 0 ? ` props-${propsRemoved.join(",")}` : ""}`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
findings.push({
|
|
331
|
+
severity: "note",
|
|
332
|
+
kind: "def-changed",
|
|
333
|
+
detail: `$defs changed: ${name} (additive or unreachable: breaking only if a curated member shape changed, reported above)`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { ok: !findings.some((f) => f.severity === "breaking"), findings };
|
|
339
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -7,7 +7,11 @@ export type MuseProviderContext = Pick<Plugin.Context, "provider" | "session" |
|
|
|
7
7
|
export interface ProviderPluginDependencies {
|
|
8
8
|
fetchCatalog?: (directory: string) => Promise<MuseCatalogSnapshot>;
|
|
9
9
|
createSessionManager?: () => MuseSessionManager;
|
|
10
|
+
/** Bounds how long setup waits for the live catalog before falling back. Defaults to 15s. */
|
|
11
|
+
setupRefreshTimeoutMs?: number;
|
|
10
12
|
}
|
|
13
|
+
/** Upper bound on delaying provider readiness for live catalog discovery. */
|
|
14
|
+
export declare const SETUP_REFRESH_TIMEOUT_MS = 15000;
|
|
11
15
|
export interface MuseProviderRuntime {
|
|
12
16
|
catalog: CatalogCache;
|
|
13
17
|
refreshTask: Promise<boolean>;
|
package/dist/provider.js
CHANGED
|
@@ -9,6 +9,8 @@ import { MuseBridgeError } from "./errors.js";
|
|
|
9
9
|
import { makeCompactionDigest, makeLocalTitle } from "./model/meta.js";
|
|
10
10
|
import { fingerprintCompactionOutput } from "./session/fingerprint.js";
|
|
11
11
|
import { extractAllowedAgentContext } from "./model/agent-context.js";
|
|
12
|
+
/** Upper bound on delaying provider readiness for live catalog discovery. */
|
|
13
|
+
export const SETUP_REFRESH_TIMEOUT_MS = 15_000;
|
|
12
14
|
function providerInfo(moduleUrl) {
|
|
13
15
|
return {
|
|
14
16
|
id: MUSE_PROVIDER_ID,
|
|
@@ -93,6 +95,23 @@ export async function createProviderPlugin(ctx, moduleUrl, dependencies = {}) {
|
|
|
93
95
|
await ctx.provider.reload();
|
|
94
96
|
});
|
|
95
97
|
});
|
|
98
|
+
// Wait for live discovery (bounded) so consumers never see the pre-refresh
|
|
99
|
+
// fallback. On timeout or failure the disabled placeholder stays in place
|
|
100
|
+
// and a late refresh still replaces it via provider.reload().
|
|
101
|
+
const refreshTimeoutMs = dependencies.setupRefreshTimeoutMs ?? SETUP_REFRESH_TIMEOUT_MS;
|
|
102
|
+
let refreshTimer;
|
|
103
|
+
try {
|
|
104
|
+
await Promise.race([
|
|
105
|
+
refreshTask.then(() => true, () => false),
|
|
106
|
+
new Promise((resolve) => {
|
|
107
|
+
refreshTimer = setTimeout(() => resolve(false), refreshTimeoutMs);
|
|
108
|
+
}),
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
if (refreshTimer !== undefined)
|
|
113
|
+
clearTimeout(refreshTimer);
|
|
114
|
+
}
|
|
96
115
|
return {
|
|
97
116
|
catalog,
|
|
98
117
|
refreshTask,
|