@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4
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/core/src/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +10 -6
- package/core/src/mcp.ts +129 -44
- package/core/src/notes.ts +51 -2
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +28 -1
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +11 -0
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +873 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +106 -5
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +280 -66
- package/src/routing.test.ts +67 -0
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- package/src/test-support/vault-714-find-path.json +58 -0
- package/src/test-support/vault-714-graph.json +27 -0
- package/src/test-support/vault-714-has_links.json +96 -0
- package/src/test-support/vault-714-include_broken_links.json +118 -0
- package/src/test-support/vault-714-include_link_count.json +348 -0
- package/src/test-support/vault-714-include_links.json +86 -0
- package/src/test-support/vault-714-near.json +78 -0
- package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
- package/src/vault.test.ts +928 -1
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- package/src/ws-subscribe.test.ts +76 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.7.9-rc.
|
|
3
|
+
"version": "0.7.9-rc.4",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"start": "bun src/server.ts",
|
|
20
20
|
"cli": "bun src/cli.ts",
|
|
21
21
|
"test": "bun test ./src/",
|
|
22
|
-
"test:core": "
|
|
22
|
+
"test:core": "bun test ./core/src/",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
25
25
|
"prepack": "bun run build:spa"
|
|
@@ -217,6 +217,82 @@ describe("REST GET /notes — aggregate: count without group_by (vault#626)", ()
|
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
describe("REST GET /notes — the filtered total's filter surface IS the list's (vault#626)", () => {
|
|
221
|
+
/**
|
|
222
|
+
* vault#626 item 1 promises the ungrouped count runs over "the same filter
|
|
223
|
+
* surface as the list query". Today that holds by construction — both
|
|
224
|
+
* `queryNotes` and `aggregateNotes` build their WHERE from the shared
|
|
225
|
+
* `buildFilterConditions` (core/src/notes.ts) — but "by construction" is
|
|
226
|
+
* exactly the kind of fact a later refactor breaks silently: a filter
|
|
227
|
+
* handled OUTSIDE the shared builder would still narrow the list while the
|
|
228
|
+
* count kept reporting the unfiltered total, and every existing test here
|
|
229
|
+
* (which only exercises `tag`) would stay green.
|
|
230
|
+
*
|
|
231
|
+
* So: for each filter, assert `count == list.length` — and assert the
|
|
232
|
+
* filter actually NARROWED (`0 < n < TOTAL`), because a param that both
|
|
233
|
+
* paths ignore would satisfy the equality vacuously.
|
|
234
|
+
*/
|
|
235
|
+
const TOTAL = 5;
|
|
236
|
+
|
|
237
|
+
async function seed(): Promise<void> {
|
|
238
|
+
await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
|
|
239
|
+
// 1 + 2 are linked to each other (the [[wikilink]] resolves by path).
|
|
240
|
+
await store.createNote("alpha [[Projects/beta]]", {
|
|
241
|
+
path: "Projects/alpha",
|
|
242
|
+
tags: ["task", "work"],
|
|
243
|
+
metadata: { status: "open" },
|
|
244
|
+
});
|
|
245
|
+
await store.createNote("beta", { path: "Projects/beta", tags: ["task"], metadata: { status: "done" } });
|
|
246
|
+
await store.createNote("gamma", { path: "Archive/gamma", tags: ["work"] });
|
|
247
|
+
await store.createNote("delta", { path: "Archive/delta", tags: ["work"] });
|
|
248
|
+
await store.createNote("untagged epsilon", { path: "Archive/epsilon" });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function listLength(qs: string): Promise<number> {
|
|
252
|
+
const res = await get(`?${qs}&limit=500`);
|
|
253
|
+
expect(res.status).toBe(200);
|
|
254
|
+
return ((await res.json()) as any[]).length;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function count(qs: string): Promise<number> {
|
|
258
|
+
const res = await get(`?${qs}&aggregate[op]=count`);
|
|
259
|
+
expect(res.status).toBe(200);
|
|
260
|
+
const body: any = await res.json();
|
|
261
|
+
expect(body).toHaveLength(1);
|
|
262
|
+
expect(body[0].group).toBeNull();
|
|
263
|
+
return body[0].value;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const CASES: [name: string, qs: string][] = [
|
|
267
|
+
["tag", "tag=task"],
|
|
268
|
+
["tag + tag_match=all", "tag=task&tag=work&tag_match=all"],
|
|
269
|
+
["exclude_tag", "exclude_tag=work"],
|
|
270
|
+
["has_tags=false", "has_tags=false"],
|
|
271
|
+
["has_links=true", "has_links=true"],
|
|
272
|
+
["path_prefix", "path_prefix=Projects/"],
|
|
273
|
+
["exclude_path_prefix", "exclude_path_prefix=Projects/"],
|
|
274
|
+
["meta[field][op]", "meta[status][eq]=open"],
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
it("the unfiltered total matches the unfiltered list", async () => {
|
|
278
|
+
await seed();
|
|
279
|
+
expect(await count("")).toBe(TOTAL);
|
|
280
|
+
expect(await listLength("")).toBe(TOTAL);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
for (const [name, qs] of CASES) {
|
|
284
|
+
it(`${name}: count == list length, and the filter really narrowed`, async () => {
|
|
285
|
+
await seed();
|
|
286
|
+
const n = await listLength(qs);
|
|
287
|
+
// Non-vacuity: a param both paths silently ignored would make the
|
|
288
|
+
// equality below trivially true.
|
|
289
|
+
expect(n).toBeGreaterThan(0);
|
|
290
|
+
expect(n).toBeLessThan(TOTAL);
|
|
291
|
+
expect(await count(qs)).toBe(n);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
220
296
|
describe("REST GET /notes — aggregate: group_by \"tag\"", () => {
|
|
221
297
|
it("counts by tag membership", async () => {
|
|
222
298
|
await store.createNote("a", { tags: ["work", "urgent"] });
|
package/src/config.ts
CHANGED
|
@@ -187,7 +187,7 @@ export interface TagSchema {
|
|
|
187
187
|
|
|
188
188
|
export interface VaultConfig {
|
|
189
189
|
name: string;
|
|
190
|
-
description?: string;
|
|
190
|
+
description?: string | null;
|
|
191
191
|
api_keys: StoredKey[];
|
|
192
192
|
created_at: string;
|
|
193
193
|
tag_schemas?: Record<string, TagSchema>;
|
|
@@ -516,7 +516,13 @@ export function defaultBackupConfig(): BackupConfig {
|
|
|
516
516
|
function serializeVaultConfig(config: VaultConfig): string {
|
|
517
517
|
const lines: string[] = [];
|
|
518
518
|
lines.push(`name: ${config.name}`);
|
|
519
|
-
|
|
519
|
+
// vault#669: only serialize a real string. A non-string that slipped
|
|
520
|
+
// past a write door used to throw here (`description.split is not a
|
|
521
|
+
// function`) and 500 the request; the doors now reject that shape, and
|
|
522
|
+
// this is the persist-time backstop so a poisoned in-memory config
|
|
523
|
+
// cannot crash the write path either. Falsy / non-string omits the
|
|
524
|
+
// field, which is how a `null` description clears.
|
|
525
|
+
if (typeof config.description === "string" && config.description.length > 0) {
|
|
520
526
|
lines.push(`description: |`);
|
|
521
527
|
for (const line of config.description.split("\n")) {
|
|
522
528
|
lines.push(` ${line}`);
|
package/src/mcp-http.test.ts
CHANGED
|
@@ -19,11 +19,21 @@
|
|
|
19
19
|
* do — `McpError` is only ever constructed inside `mcp-http.ts` itself).
|
|
20
20
|
*/
|
|
21
21
|
import { describe, test, expect } from "bun:test";
|
|
22
|
+
import { Database } from "bun:sqlite";
|
|
22
23
|
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
23
24
|
import { handleMcp, mcpDomainError } from "./mcp-http.ts";
|
|
24
|
-
import type
|
|
25
|
+
import { generateMcpTools, type McpToolDef } from "../core/src/mcp.ts";
|
|
26
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
25
27
|
import type { AuthResult } from "./auth.ts";
|
|
26
28
|
|
|
29
|
+
interface ToolErrorResponse {
|
|
30
|
+
error: {
|
|
31
|
+
code: number;
|
|
32
|
+
message: string;
|
|
33
|
+
data: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
function toolsWith(execute: (params: Record<string, unknown>) => unknown): () => McpToolDef[] {
|
|
28
38
|
return () => [
|
|
29
39
|
{
|
|
@@ -64,6 +74,29 @@ async function callBoom(execute: (params: Record<string, unknown>) => unknown):
|
|
|
64
74
|
return res.json();
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
async function callNoteToolWithoutId(
|
|
78
|
+
name: "update-note" | "delete-note",
|
|
79
|
+
): Promise<ToolErrorResponse> {
|
|
80
|
+
const db = new Database(":memory:");
|
|
81
|
+
try {
|
|
82
|
+
const store = new SqliteStore(db);
|
|
83
|
+
const req = new Request("http://localhost:1940/vault/v/mcp", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
jsonrpc: "2.0",
|
|
88
|
+
id: 1,
|
|
89
|
+
method: "tools/call",
|
|
90
|
+
params: { name, arguments: {} },
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
const res = await handleMcp(req, () => generateMcpTools(store), "test-server", "v", fullAuth(), "");
|
|
94
|
+
return (await res.json()) as ToolErrorResponse;
|
|
95
|
+
} finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
67
100
|
describe("MCP SDK McpError — confirms the mechanics this fix depends on", () => {
|
|
68
101
|
test("the SDK bakes the JSON-RPC prefix into .message itself, not just .toString()", () => {
|
|
69
102
|
const err = new McpError(ErrorCode.InvalidParams, "bad stuff happened", { error_type: "invalid_query" });
|
|
@@ -150,3 +183,20 @@ describe("handleMcp JSON-RPC error mapping — end to end (vault#555 fix 6)", ()
|
|
|
150
183
|
expect(body.result.content[0].text).toContain("plain unstructured failure");
|
|
151
184
|
});
|
|
152
185
|
});
|
|
186
|
+
|
|
187
|
+
describe("note mutation tools require an id (vault#705)", () => {
|
|
188
|
+
for (const name of ["update-note", "delete-note"] as const) {
|
|
189
|
+
test(`${name} without id returns structured invalid params, never a TypeError`, async () => {
|
|
190
|
+
const body = await callNoteToolWithoutId(name);
|
|
191
|
+
|
|
192
|
+
expect(body.error.code).toBe(ErrorCode.InvalidParams);
|
|
193
|
+
expect(body.error.data).toMatchObject({
|
|
194
|
+
error_type: "missing_required_field",
|
|
195
|
+
field: "id",
|
|
196
|
+
hint: expect.stringContaining("path"),
|
|
197
|
+
});
|
|
198
|
+
expect(body.error.message).not.toContain("TypeError");
|
|
199
|
+
expect(body.error.message).not.toContain("idOrPath.match");
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
package/src/mcp-tools.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
9
9
|
import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
|
|
10
10
|
import { getNoteTags, getVaultMap } from "../core/src/notes.ts";
|
|
11
|
+
import { narrowLinkWarningsForVisibility } from "../core/src/wikilinks.ts";
|
|
11
12
|
import type { Note } from "../core/src/types.ts";
|
|
12
13
|
import {
|
|
13
14
|
buildVaultProjection,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
expandTokenTagScope,
|
|
24
25
|
filterHydratedLinksByTagScope,
|
|
25
26
|
noteWithinTagScope,
|
|
27
|
+
scopeQueryTagParam,
|
|
26
28
|
scrubIndexedFieldConflictError,
|
|
27
29
|
scrubNoteTagsByScope,
|
|
28
30
|
scrubParentCycleError,
|
|
@@ -211,6 +213,24 @@ export function generateScopedMcpTools(
|
|
|
211
213
|
)
|
|
212
214
|
: undefined;
|
|
213
215
|
|
|
216
|
+
// Tag-scope guard for the ambiguous-links surface (vault#581 auth review):
|
|
217
|
+
// `candidate_count` is derived vault-wide, so without this a `work`-scoped
|
|
218
|
+
// reader learns from `candidate_count: 2` on its own `[[Dup]]` that a
|
|
219
|
+
// second `Dup` exists in a scope it cannot see — and `has_ambiguous_links:
|
|
220
|
+
// true` turns that into a standing, sweepable census of cross-scope naming
|
|
221
|
+
// collisions. Identical closure/shape to `nearTraversable` above (by note
|
|
222
|
+
// id, tags looked up sync + core-native); core narrows each row's
|
|
223
|
+
// candidates through it. Unscoped sessions install no predicate, so the
|
|
224
|
+
// persisted counts and the SQL filter answer directly, unchanged.
|
|
225
|
+
const ambiguityVisible = scoped
|
|
226
|
+
? (noteId: string) =>
|
|
227
|
+
noteWithinTagScope(
|
|
228
|
+
{ id: noteId, tags: getNoteTags(store.db, noteId) } as Note,
|
|
229
|
+
allowedHolder.value,
|
|
230
|
+
rawTags,
|
|
231
|
+
)
|
|
232
|
+
: undefined;
|
|
233
|
+
|
|
214
234
|
// Tag-scope guard for `create-note` `if_exists` (vault#555 auth-review
|
|
215
235
|
// must-fix): the core `if_exists` upsert resolves the target path VAULT-WIDE
|
|
216
236
|
// and returns/updates/replaces the found note, so a scoped caller could
|
|
@@ -274,6 +294,7 @@ export function generateScopedMcpTools(
|
|
|
274
294
|
...(nearTraversable ? { nearTraversable } : {}),
|
|
275
295
|
...(ifExistsVisible ? { ifExistsVisible } : {}),
|
|
276
296
|
...(aggregateVisibility ? { aggregateVisibility } : {}),
|
|
297
|
+
...(ambiguityVisible ? { ambiguityVisible } : {}),
|
|
277
298
|
...(writeContext ? { writeContext } : {}),
|
|
278
299
|
...(strictBypass ? { strictBypass } : {}),
|
|
279
300
|
...(onStrictBypass ? { onStrictBypass } : {}),
|
|
@@ -439,6 +460,38 @@ function applyTagScopeWrappers(
|
|
|
439
460
|
return scrubNoteTagsByScope(n, allowedHolder?.value ?? null, rawTags);
|
|
440
461
|
};
|
|
441
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Re-decide a write response's `ambiguous_link` warnings against the
|
|
465
|
+
* caller's OWN sub-vault (vault#707's rule, write side). `candidate_count`
|
|
466
|
+
* is derived vault-wide, so a `work`-scoped writer saving `[[Dup]]` would
|
|
467
|
+
* otherwise learn from `candidate_count: 2` that a second `Dup` exists in a
|
|
468
|
+
* scope it cannot see — the same oracle vault#707 closed on `query-notes`,
|
|
469
|
+
* reachable through `create-note` / `update-note` instead. Same visibility
|
|
470
|
+
* model as `ambiguityVisible` / `nearTraversable` in generateScopedMcpTools
|
|
471
|
+
* (`noteWithinTagScope` by note id), and the same decision function in core
|
|
472
|
+
* (`visibleResolutionCount`), so the surfaces cannot drift.
|
|
473
|
+
*
|
|
474
|
+
* Ordering: identical to `scrubNoteForScope` above — every write wrapper
|
|
475
|
+
* `await getAllowed()`s before `orig(params)`, so `allowedHolder.value` is
|
|
476
|
+
* the resolved allowlist by the time this runs.
|
|
477
|
+
*/
|
|
478
|
+
const scrubWriteNote = (n: any): any => {
|
|
479
|
+
const scrubbed = scrubNoteForScope(n);
|
|
480
|
+
if (!scrubbed || !Array.isArray(scrubbed.warnings)) return scrubbed;
|
|
481
|
+
const visible = (noteId: string) =>
|
|
482
|
+
noteWithinTagScope(
|
|
483
|
+
{ id: noteId, tags: getNoteTags(store.db, noteId) } as Note,
|
|
484
|
+
allowedHolder?.value ?? null,
|
|
485
|
+
rawTags,
|
|
486
|
+
);
|
|
487
|
+
const warnings = narrowLinkWarningsForVisibility(store.db, scrubbed.warnings, visible);
|
|
488
|
+
if (warnings.length === 0) {
|
|
489
|
+
const { warnings: _dropped, ...rest } = scrubbed;
|
|
490
|
+
return rest;
|
|
491
|
+
}
|
|
492
|
+
return { ...scrubbed, warnings };
|
|
493
|
+
};
|
|
494
|
+
|
|
442
495
|
/**
|
|
443
496
|
* Shape dispatcher for the WRITE tools (vault#568). `create-note` and
|
|
444
497
|
* `update-note` echo the stored note, so they leak exactly what the read
|
|
@@ -449,18 +502,46 @@ function applyTagScopeWrappers(
|
|
|
449
502
|
*/
|
|
450
503
|
const scrubWriteResult = (result: any): any => {
|
|
451
504
|
if (!result || typeof result !== "object") return result;
|
|
452
|
-
if (Array.isArray(result)) return result.map(
|
|
505
|
+
if (Array.isArray(result)) return result.map(scrubWriteNote);
|
|
453
506
|
if ("error" in result) return result;
|
|
454
507
|
if (Array.isArray((result as any).notes)) {
|
|
455
|
-
return { ...result, notes: (result as any).notes.map(
|
|
508
|
+
return { ...result, notes: (result as any).notes.map(scrubWriteNote) };
|
|
456
509
|
}
|
|
457
|
-
if ("id" in result && "tags" in result) return
|
|
510
|
+
if ("id" in result && "tags" in result) return scrubWriteNote(result);
|
|
458
511
|
return result;
|
|
459
512
|
};
|
|
460
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Neutralise out-of-scope tags NAMED IN THE QUERY (vault#675) before core
|
|
516
|
+
* ever runs. Without this a scoped session could ask `query-notes { tag:
|
|
517
|
+
* "project-manhattan" }` and read the answer off hit/miss — a co-tagged
|
|
518
|
+
* note is in scope via its OTHER tag, so it survived the result filter
|
|
519
|
+
* below and confirmed the guessed name. Rewriting the input (rather than
|
|
520
|
+
* emptying the output) keeps the response byte-identical to the same call
|
|
521
|
+
* naming a tag that doesn't exist, in every shape this tool returns —
|
|
522
|
+
* including the `aggregate` rollup and the `next_cursor` envelope, which
|
|
523
|
+
* an output-side patch could not reproduce. `exclude_tags` is rewritten on
|
|
524
|
+
* the same terms (core skips an exclude clause that can't match, so it
|
|
525
|
+
* excludes nothing — again exactly a nonexistent tag). Aliases mirror
|
|
526
|
+
* core's `exclude_tags ?? excludeTags ?? exclude_tag` resolution order.
|
|
527
|
+
*/
|
|
528
|
+
const QUERY_TAG_PARAMS = ["tag", "exclude_tags", "excludeTags", "exclude_tag"] as const;
|
|
529
|
+
const scopeQueryTagParams = (params: any, allowed: Set<string> | null): any => {
|
|
530
|
+
if (!params || typeof params !== "object") return params;
|
|
531
|
+
let next = params;
|
|
532
|
+
for (const key of QUERY_TAG_PARAMS) {
|
|
533
|
+
if (params[key] === undefined) continue;
|
|
534
|
+
const scoped = scopeQueryTagParam(params[key], allowed, rawTags);
|
|
535
|
+
if (scoped === params[key]) continue;
|
|
536
|
+
if (next === params) next = { ...params };
|
|
537
|
+
next[key] = scoped;
|
|
538
|
+
}
|
|
539
|
+
return next;
|
|
540
|
+
};
|
|
541
|
+
|
|
461
542
|
wrapReadTool(tools, "query-notes", async (orig, params) => {
|
|
462
543
|
const allowed = await getAllowed();
|
|
463
|
-
const result = await orig(params);
|
|
544
|
+
const result = await orig(scopeQueryTagParams(params, allowed));
|
|
464
545
|
if (!allowed) return result;
|
|
465
546
|
// `aggregate` mode returns `[{group, value}]` rollup rows — no `.tags`
|
|
466
547
|
// to post-filter (and `noteWithinTagScope` would wrongly drop every
|
|
@@ -863,7 +944,27 @@ function overrideVaultInfo(
|
|
|
863
944
|
`Forbidden: updating the vault description requires the 'vault:admin' scope (or 'vault:${vaultName}:admin'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
|
|
864
945
|
);
|
|
865
946
|
}
|
|
866
|
-
|
|
947
|
+
// vault#669: runtime type guard — the `as string` cast below was a
|
|
948
|
+
// compile-time claim guarding nothing, so a write/admin caller could
|
|
949
|
+
// persist a non-string `description` (or crash serializeVaultConfig
|
|
950
|
+
// with `description.split is not a function`) and poison the NEXT
|
|
951
|
+
// MCP `initialize` (projectionToMarkdown's `description.trim()`).
|
|
952
|
+
// Reject non-string/non-null here as a structured invalid-params
|
|
953
|
+
// error: a duck-typed `error_type` throw, which src/mcp-http.ts's
|
|
954
|
+
// generic branch turns into JSON-RPC -32602 INVALID_PARAMS carrying
|
|
955
|
+
// structured `data` — the same shape core's own validation leaves
|
|
956
|
+
// take, no new error family. `null` stays legal: it clears the
|
|
957
|
+
// description. Cross-door parity with cloud#263 (closes the bun
|
|
958
|
+
// half of cloud#87).
|
|
959
|
+
if (params.description !== null && typeof params.description !== "string") {
|
|
960
|
+
throw Object.assign(new Error("description must be a string or null"), {
|
|
961
|
+
error_type: "invalid_description",
|
|
962
|
+
field: "description",
|
|
963
|
+
got: params.description,
|
|
964
|
+
hint: "pass a string, or null to clear the description",
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
config.description = params.description as string | null;
|
|
867
968
|
writeVaultConfig(config);
|
|
868
969
|
}
|
|
869
970
|
|
|
@@ -440,6 +440,53 @@ describe("handleMirrorPut", () => {
|
|
|
440
440
|
expect(manager.getConfig().safety_net_seconds).toBe(120);
|
|
441
441
|
await manager.stop();
|
|
442
442
|
});
|
|
443
|
+
|
|
444
|
+
test("two concurrent PUTs both 200 and leave a live watcher (safety envelope, vault#662)", async () => {
|
|
445
|
+
// From #661's review: the serialized sibling above soundly proves the
|
|
446
|
+
// only promised behavior (sequential last-writer-wins). Concurrent
|
|
447
|
+
// execution still has residual value — both PUTs 200, watcher stays
|
|
448
|
+
// live, final safety_net_seconds is one of the two requested values.
|
|
449
|
+
// The product makes no concurrent-winner promise, so we do not assert
|
|
450
|
+
// which config won.
|
|
451
|
+
home = tmp("mirror-put-concurrent-");
|
|
452
|
+
const { manager } = makeManager(home);
|
|
453
|
+
const put = (body: Record<string, unknown>) =>
|
|
454
|
+
handleMirrorPut(
|
|
455
|
+
new Request("http://x/admin/mirror", {
|
|
456
|
+
method: "PUT",
|
|
457
|
+
body: JSON.stringify(body),
|
|
458
|
+
}),
|
|
459
|
+
manager,
|
|
460
|
+
);
|
|
461
|
+
const [res1, res2] = await Promise.all([
|
|
462
|
+
put({
|
|
463
|
+
enabled: true,
|
|
464
|
+
location: "internal",
|
|
465
|
+
sync_mode: "events",
|
|
466
|
+
auto_commit: false,
|
|
467
|
+
safety_net_seconds: 60,
|
|
468
|
+
}),
|
|
469
|
+
put({
|
|
470
|
+
enabled: true,
|
|
471
|
+
location: "internal",
|
|
472
|
+
sync_mode: "events",
|
|
473
|
+
auto_commit: false,
|
|
474
|
+
safety_net_seconds: 120,
|
|
475
|
+
}),
|
|
476
|
+
]);
|
|
477
|
+
expect(res1.status).toBe(200);
|
|
478
|
+
expect(res2.status).toBe(200);
|
|
479
|
+
const status = manager.getStatus();
|
|
480
|
+
expect(status.enabled).toBe(true);
|
|
481
|
+
expect(status.watch_running).toBe(true);
|
|
482
|
+
expect([60, 120]).toContain(manager.getConfig().safety_net_seconds);
|
|
483
|
+
// Concurrent start() can re-arm timers after the other PUT's stop().
|
|
484
|
+
// Drain once more so this test cannot leak a safety-net interval into
|
|
485
|
+
// the rest of the suite.
|
|
486
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
487
|
+
await manager.stop();
|
|
488
|
+
await manager.stop();
|
|
489
|
+
});
|
|
443
490
|
});
|
|
444
491
|
|
|
445
492
|
// ---------------------------------------------------------------------------
|
package/src/release-plan.test.ts
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
import { describe, expect, test } from "bun:test";
|
|
12
12
|
import {
|
|
13
13
|
compareVersions,
|
|
14
|
+
coreVersion,
|
|
14
15
|
decidePublish,
|
|
15
16
|
distTagFor,
|
|
17
|
+
matchingRcVersions,
|
|
16
18
|
readRegistry,
|
|
17
19
|
unpublishedDrift,
|
|
18
20
|
} from "../scripts/release-plan.ts";
|
|
@@ -193,13 +195,81 @@ describe("decidePublish", () => {
|
|
|
193
195
|
{
|
|
194
196
|
versionExists: false,
|
|
195
197
|
currentDistTagVersion: "0.7.8",
|
|
196
|
-
publishedVersions: ["0.7.8"],
|
|
198
|
+
publishedVersions: ["0.7.8", "0.7.9-rc.1"],
|
|
197
199
|
},
|
|
198
200
|
{ branch: "main" },
|
|
199
201
|
);
|
|
200
202
|
expect(d).toMatchObject({ publish: true });
|
|
201
203
|
});
|
|
202
204
|
|
|
205
|
+
test("a stable without a matching rc is refused — vault#697", () => {
|
|
206
|
+
// Hub 0.7.13–0.7.16 shipped @latest with no rc of the same X.Y.Z.
|
|
207
|
+
// Vault's previous plan (and the inline shell it replaced) did the same.
|
|
208
|
+
const d = decidePublish(
|
|
209
|
+
"0.7.9",
|
|
210
|
+
{
|
|
211
|
+
versionExists: false,
|
|
212
|
+
currentDistTagVersion: "0.7.8",
|
|
213
|
+
publishedVersions: ["0.7.8", "0.7.8-rc.2"],
|
|
214
|
+
},
|
|
215
|
+
{ branch: "main" },
|
|
216
|
+
);
|
|
217
|
+
expect(d).toMatchObject({ refuse: true });
|
|
218
|
+
expect("refuse" in d && d.reason).toMatch(/0\.7\.9-rc/);
|
|
219
|
+
expect("refuse" in d && d.reason).toMatch(/suffix-drop|Cut an rc first/i);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a stable whose only published rcs are a different X.Y.Z is still refused", () => {
|
|
223
|
+
const d = decidePublish(
|
|
224
|
+
"0.7.9",
|
|
225
|
+
{
|
|
226
|
+
versionExists: false,
|
|
227
|
+
currentDistTagVersion: "0.7.8",
|
|
228
|
+
publishedVersions: ["0.7.8", "0.7.8-rc.1"],
|
|
229
|
+
},
|
|
230
|
+
{ branch: "main" },
|
|
231
|
+
);
|
|
232
|
+
expect(d).toMatchObject({ refuse: true });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a stable with a matching rc publishes from main", () => {
|
|
236
|
+
const d = decidePublish(
|
|
237
|
+
"0.7.9",
|
|
238
|
+
{
|
|
239
|
+
versionExists: false,
|
|
240
|
+
currentDistTagVersion: "0.7.8",
|
|
241
|
+
publishedVersions: ["0.7.8", "0.7.9-rc.1"],
|
|
242
|
+
},
|
|
243
|
+
{ branch: "main" },
|
|
244
|
+
);
|
|
245
|
+
expect(d).toMatchObject({ publish: true });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("omitted publishedVersions still refuses a stable when latest already exists", () => {
|
|
249
|
+
const d = decidePublish(
|
|
250
|
+
"0.7.9",
|
|
251
|
+
{
|
|
252
|
+
versionExists: false,
|
|
253
|
+
currentDistTagVersion: "0.7.8",
|
|
254
|
+
},
|
|
255
|
+
{ branch: "main" },
|
|
256
|
+
);
|
|
257
|
+
expect(d).toMatchObject({ refuse: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("an rc publishes even with no prior rc of the same core", () => {
|
|
261
|
+
const d = decidePublish(
|
|
262
|
+
"0.7.9-rc.1",
|
|
263
|
+
{
|
|
264
|
+
versionExists: false,
|
|
265
|
+
currentDistTagVersion: "0.7.8-rc.5",
|
|
266
|
+
publishedVersions: ["0.7.8", "0.7.8-rc.5"],
|
|
267
|
+
},
|
|
268
|
+
{ branch: "next" },
|
|
269
|
+
);
|
|
270
|
+
expect(d).toMatchObject({ publish: true });
|
|
271
|
+
});
|
|
272
|
+
|
|
203
273
|
test("a tag push of a stable does NOT override the main-only gate", () => {
|
|
204
274
|
const d = decidePublish(
|
|
205
275
|
"0.7.9",
|
|
@@ -281,6 +351,25 @@ describe("readRegistry", () => {
|
|
|
281
351
|
});
|
|
282
352
|
});
|
|
283
353
|
|
|
354
|
+
describe("matchingRcVersions", () => {
|
|
355
|
+
test("matches only the same X.Y.Z rc chain", () => {
|
|
356
|
+
expect(
|
|
357
|
+
matchingRcVersions("0.7.9", ["0.7.8", "0.7.8-rc.1", "0.7.9-rc.1", "0.7.9-rc.2"]),
|
|
358
|
+
).toEqual(["0.7.9-rc.1", "0.7.9-rc.2"]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("a prerelease still matches siblings of its core", () => {
|
|
362
|
+
expect(matchingRcVersions("0.7.9-rc.3", ["0.7.9-rc.1", "0.7.8-rc.1"])).toEqual(["0.7.9-rc.1"]);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("coreVersion", () => {
|
|
367
|
+
test("strips the rc suffix and leaves a stable alone", () => {
|
|
368
|
+
expect(coreVersion("0.7.9-rc.1")).toBe("0.7.9");
|
|
369
|
+
expect(coreVersion("0.7.9")).toBe("0.7.9");
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
284
373
|
describe("unpublishedDrift", () => {
|
|
285
374
|
test("no commits → not drifted", () => {
|
|
286
375
|
expect(unpublishedDrift([]).drifted).toBe(false);
|