ast-mcp-server 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -6,7 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.13.0] — local release candidate, pending release (2026-08-27)
9
+ ## [0.14.0] — not yet published (2026-10-25)
10
+
11
+ ### Changed
12
+
13
+ - Migrated schema validation to Zod 4 (`^4.6.7`). The published `tools/list` contract changes shape and Zod's validation error messages differ: tool input schemas no longer all declare `additionalProperties: false` (16 of 16 before, 1 of 16 now), shared `$ref` definitions are inlined (27 before, 0 now), the serialized `tools/list` grows from 100,923 to 126,562 bytes, integer fields declare a maximum of 9007199254740991, and UUID fields gain a `pattern`. Clients that cache or diff the advertised schemas should refresh them.
14
+
15
+ ### Security
16
+
17
+ - Upgraded `@modelcontextprotocol/sdk` to `^1.32.1` (GHSA-6qxp-vccf-f47h), `body-parser` to 2.4.0, and `zod` to 4.6.7 for the memory-exhaustion advisory, and raised the `hono` and `fast-uri` resolutions to 4.13.13 and 3.1.9. `yarn audit` reports no suggestions.
18
+
19
+ ## [0.13.1] — published to `latest` and `next`, registry verified (2026-09-01)
20
+
21
+ ### Fixed
22
+
23
+ - The DeepSeek Harness adapter now opts into a bounded canonical-JSON text projection when a successful MCP result has structured data but no ordinary text, preserving lossless `structuredContent` while making native Agent/Session results useful and durable. The mandatory pinned-host smoke freezes the public 0.13.0 RED baseline and verifies the released correction across the next model request, persisted `tool/result`, cold replay, guarded catalog, and cleanup readback.
24
+ - `ast_explore` now publishes its complete object input schema while retaining all cross-field runtime rejection, and `ast_get_impact` no longer advertises one false output shape across JSON and TOON. The pinned Harness gate binds the public empty-schema RED to released registry/native parity and unchanged hashes for the other 14 model schemas.
25
+ - Timeout ownership is now explicit across the Harness, queue, and AST worker: the shipped outer timeout exceeds the complete queue/execution/margin budget, deterministic slow work returns bounded AST-owned operational errors, queued work never starts after its deadline, cancellation remains correlated across worker generations, and cleanup proves zero active work, held requests, listeners, or owned processes.
26
+ - Harness lifecycle handling now converges deterministically through MCP removal and reconnect, rejects retired-generation effects, preserves AST `REQUEST_CANCELLED` authority separately from bridge transport observations, and completes correlated shutdown without residual state. The pinned native and rendered-GUI gates verify the `15→0→15` catalog sequence, request/header identity, secret-safe diagnostics, and ordered cleanup that still runs every owner check after an earlier cleanup failure.
27
+
28
+ ### Release status
29
+
30
+ - npm published immutable `0.13.1` bytes with integrity `sha512-jqgGoYs8fe7J+E25lZusLK4wV6sjM5n5qiWnfe1RJIxOFo1r5nbtcBr1a/fdSWTYf/37bUNkshQp86UrdBHOsA==`. Public-registry verification passed; `latest` and `next` both resolve to `0.13.1`; and npm `gitHead`, the annotated `v0.13.1` tag, and the GitHub Release target exact commit `27b80a3da169b473a3b5c5dfea69ed52903ed4c7`.
31
+ - Main CI `33460585683`, Security `33460585625`, publish `33461292810`, verification `33461385440`, and promotion retry `33461651288` are green. Initial promotion run `33461571363` changed `latest` successfully but its bounded readback timed out; the verified idempotent retry is authoritative.
32
+
33
+ ## [0.13.0] — published to `latest` and `next`, registry verified (2026-08-29)
10
34
 
11
35
  ### Added
12
36
 
package/README.md CHANGED
@@ -46,8 +46,8 @@ Reads can start with a bounded file slice, a compact outline, or exact source on
46
46
  - Use `ast_explore` when the question spans discovery and evidence. Its default summary is bounded; use `detail: "context"` for selected source and `detail: "full"` for source plus compiler references.
47
47
  - Use `ast_get_outline` for a compact body-free view of a known file without source lines.
48
48
  - Use `ast_get_symbol_source` when one declaration or implementation is the required evidence.
49
- - Use `ast_get_impact` when the exact symbol is known and bounded direct/transitive compiler relationships are needed; it is read-only evidence, not a mutation plan.
50
- - Use `ast_find_test_candidates` when an exact symbol should map to conservative test candidates. It forces incoming compiler traversal, returns complete relationship paths, and never executes tests.
49
+ - Use `ast_get_impact` when the exact symbol is known and bounded direct/transitive compiler relationships are needed. Check `coverage`, `work`, `truncation`, `incomplete`, and `proven_empty`; this is read-only evidence, not a mutation plan.
50
+ - Use `ast_find_test_candidates` when an exact symbol should map to conservative tests. It admits only complete incoming `reference`, `import`, `export`, `extends`, `implements`, and `call` evidence, returns whole paths, and never executes tests.
51
51
 
52
52
  `snapshot_state: "fresh"` means that the returned file bytes match the synchronized compiler snapshot. The separate `freshness` object describes the project/session state and preserves causes such as source changes or watcher failure. Neither field means that the project has zero TypeScript diagnostics; use `ast_get_diagnostics` for compiler errors and warnings.
53
53
 
@@ -64,7 +64,20 @@ The server exposes evidence labels instead of collapsing every result into an un
64
64
 
65
65
  Freshness is orthogonal to TypeScript diagnostics. `fresh` means the evidence matches the synchronized snapshot; `pending`, `rebuilding`, `stale`, or `degraded` means the response must not be presented as current compiler evidence. Read tools expose the state, causes (`source_change`, `config_change`, `index_failure`, `watcher_failure`, or `compiler_rebuild`), and bounded `checked_at` timestamp. `ast_get_impact` refuses non-fresh compiler relationships. `ast_explore` returns the state together with `completeness`, `unresolved`, `budget`, and `truncation` metadata rather than silently dropping evidence.
66
66
 
67
- All reads are budgeted. Callers control pagination and, where applicable, `max_bytes`, `reference_limit`, `max_depth`, `max_nodes`, and `max_edges`; responses report the effective limits and whether a record, byte, depth, edge, invocation, or serialization limit truncated the result. A truncated or unresolved result is incomplete evidence, not an empty negative result. `ast_find_test_candidates` follows the same rule: it accepts only fresh, exact compiler-backed impact, emits direct/transitive evidence and bounded relationship IDs, and never executes tests or guesses from filenames alone. Only a complete authoritative traversal may return `candidates: []` with `proven_empty: true`.
67
+ All reads are budgeted. Callers control pagination and, where applicable, `max_bytes`, `reference_limit`, `max_depth`, `max_nodes`, and `max_edges`; responses report effective limits. A bounded stop is incomplete evidence, not an empty negative result.
68
+
69
+ Impact has two independent completeness channels:
70
+
71
+ | Channel | Evidence | Incomplete when |
72
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
73
+ | Semantic coverage | One canonical cell per requested kind, direction, and endpoint class: `not_applicable`, `completed`, `unsupported`, or `unfinished` | Any applicable cell is `unsupported` or `unfinished` |
74
+ | Traversal/work | `truncation` plus `work.work_limit_reached` and its counters | A depth/node/edge/work bound prevents completion |
75
+
76
+ `incomplete` is true when either channel is incomplete. `proven_empty` is true only when there are zero edges, every applicable cell completed, and no bound was exhausted. The public seven-kind/default impact request includes `contains`; because no scoped `contains` producer exists, applicable default or explicit containment is `unsupported`, so the result is incomplete even when traversal was not truncated.
77
+
78
+ `ast_find_test_candidates` deliberately excludes `contains` and fixes the admission scope to six incoming kinds: `reference`, `import`, `export`, `extends`, `implements`, and `call`. It returns `INCOMPLETE_EVIDENCE` before pagination for stale, inexact, unresolved, truncated, work-exhausted, unsupported, or unfinished evidence. Only complete six-kind authority may return `candidates: []` with `completeness.proven_empty: true`; pagination slices deterministic candidates while each proof and the unpaginated `coverage`, `work`, and traversal metadata stay whole.
79
+
80
+ These fields are additive: existing edge shapes, kind strings, defaults, and public errors remain stable. MCP `ast_get_impact` JSON and TOON represent the same logical coverage/work result; candidate MCP output remains canonical JSON, while read-only batch output may encode that same candidate result as TOON. Property, element, dynamic, computed-key, and external-alternative dispatch remains edge-free and unfinished—this contract does not certify the deferred #219 or #220 classifiers.
68
81
 
69
82
  ## Why this helps
70
83
 
@@ -84,7 +97,7 @@ The included batch benchmark records a 50% reduction in model round-trips and a
84
97
 
85
98
  ## Supported environment and trust boundary
86
99
 
87
- Published v0.12.0 requires Node.js `>=22.13.0`; its immutable evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
100
+ Published v0.12.0 requires Node.js `>=22.13.0`; its immutable evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Structural apply and managed setup-file publication are verified only on Linux x64 with GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, `O_DIRECTORY`/`O_NOFOLLOW`, and a destination filesystem that passes the owned link/exchange identity probe. A failed or denied primitive blocks mutation before source effects; there is no rename, copy/delete, or pathname-only fallback. Other Linux architectures or systems without this complete matrix, macOS, and Windows remain unverified.
88
101
 
89
102
  This is a local stdio server. It runs with the invoking user's filesystem permissions, and clients may request any `project_root` that user can access. It does not provide HTTP authentication, sandboxing, tenant isolation, or a remote-service security boundary. Remote, untrusted, and multi-tenant operation is unsupported.
90
103
 
@@ -329,30 +342,47 @@ A thin adapter ships inside this package: `cordis.patch.yml` mounts the packaged
329
342
  [`@deepseek-ai/dsh-mcp-client`](https://github.com/deepseek-ai/deepseek-harness/blob/cd5ef8148158c3a752a658978873241fdf8e2bbc/packages/mcp/mcp-client/README.md)
330
343
  bridge, declared through exactly `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`.
331
344
  This is a Developer Preview against a pinned, **source-built** Harness revision
332
- (`dsh-v0.1.2-alpha.1` at `cd5ef8148158c3a752a658978873241fdf8e2bbc`); that revision
333
- is validated from source, and `ast-mcp-server@0.13.0` is not published yet. Install a
334
- locally packed tarball instead:
345
+ (`dsh-v0.1.2-alpha.1` at `cd5ef8148158c3a752a658978873241fdf8e2bbc`). The published
346
+ `ast-mcp-server@0.13.0` package is the compatibility baseline:
347
+
348
+ ```bash
349
+ dsh plugin --profile web add ast-mcp-server@0.13.0
350
+ ```
351
+
352
+ For a local candidate, pack and install its tarball instead:
335
353
 
336
354
  ```bash
337
355
  yarn pack --out ast-mcp-server-%v.tgz
338
- dsh plugin --profile web add ./ast-mcp-server-0.13.0.tgz
356
+ dsh plugin --profile web add ./ast-mcp-server-0.14.0.tgz
339
357
  ```
340
358
 
341
359
  The first supported surface is **reads + prepare + preview**. Every apply path is
342
360
  denied by a fail-closed guard: `ast_apply_operation` is not registered unless
343
361
  `AST_MCP_APPLY_GUARD=allow` is set explicitly (the shipped patch instead pins `deny`,
344
- so the Harness surface stays deny-by-default; an unset or invalid value also denies). Known upstream
345
- gaps apply and are never safety authority: the official bridge drops MCP tool
346
- annotations (`readOnlyHint`/`destructiveHint`) and launches the stdio child outside
347
- the Harness sandbox.
348
-
349
- `yarn test:dsh-adapter` is the mandatory verification: it packs the exact tarball,
350
- installs it into an isolated profile on the pinned Harness, proves `--dump-config`
351
- composition including effective `tools.mode: native`, and — through the Harness registry
352
- itself — invokes the server-qualified read, dry-run rename prepare, and preview tools.
353
- It separately proves `mcp__ast__ast_apply_operation` is absent and that a bounded direct
354
- registry invocation is rejected. The smoke fails (never skips) if the pinned Harness
355
- revision, tag, CLI/client version, clean source identity, or a prerequisite is missing.
362
+ so the Harness surface stays deny-by-default; an unset or invalid value also denies).
363
+ The adapter also sets `AST_MCP_TEXT_PROJECTION=canonical_json`: successful structured
364
+ results keep their lossless `structuredContent` and gain canonical JSON text only when
365
+ ordinary MCP text is empty, because the pinned native presenter otherwise exposes only a
366
+ non-useful empty-result marker. The projection is adapter-specific, never replaces existing
367
+ text, and reports an explicit size-limit marker when the complete supervised frame has room.
368
+ If even the unchanged structured-only result exceeds the existing worker frame, supervised mode
369
+ fails closed rather than truncating or corrupting it. Known upstream gaps remain non-authoritative:
370
+ the official bridge drops MCP tool annotations
371
+ (`readOnlyHint`/`destructiveHint`) and launches the stdio child outside the Harness sandbox.
372
+
373
+ `yarn test:dsh-adapter` is the mandatory verification. It binds the public 0.13.0 npm
374
+ integrity, packs the candidate, builds the pinned Harness and bridge from source, and proves
375
+ `tools.mode: native`, the 15-tool scoped catalog, complete executable input schemas,
376
+ read/prepare/preview, all three invalid `ast_explore` combinations, apply absence, and rejected direct apply.
377
+ The schema gate freezes the public empty `ast_explore` contract as RED, requires the candidate
378
+ registry/native definitions to match, and hashes all 14 unaffected model schemas. A deterministic
379
+ two-step model then invokes
380
+ `mcp__ast__ast_get_project_status` through a real headless Agent/Session: the public baseline
381
+ must reproduce the empty-result marker, while the corrected candidate must deliver lossless
382
+ canonical JSON to the next model request, the durable `tool/result`, and a cold Agent
383
+ resume/replay reconstructed from persistence. The smoke removes and read-backs its disposable
384
+ profile/workspace state and fails
385
+ (never skips) on an identity mismatch, missing prerequisite, lifecycle leak, or evidence gap.
356
386
 
357
387
  ## MCP tools
358
388
 
@@ -512,12 +542,13 @@ Apply loads the exact retained postimages, requires the separately supplied revi
512
542
 
513
543
  ### Guarantee boundary
514
544
 
515
- The server does **not** claim a filesystem-wide transaction:
545
+ The server does **not** claim a filesystem-wide transaction or safety against a continuously mutating writer:
516
546
 
517
- - Replacement is atomic per file where the local filesystem provides atomic rename.
518
- - A multi-file apply has a short interval in which some replacements may already be visible.
519
- - Rollback is best effort and refuses to overwrite a file changed by another writer after replacement.
520
- - MCP and CLI apply share a fail-closed filesystem lock keyed by canonical `tsconfig.json` when they use the same state directory. It does not coordinate editors, NFS writers, or hostile external processes.
547
+ - Each replacement exchanges and authenticates one staged/destination pair. Multi-file apply remains sequential, so earlier postimages may be visible before later files commit.
548
+ - Reverse rollback restores only an exact operation-owned pair. Lost ownership preserves the observable entries and returns `AMBIGUOUS_APPLY`; it never falls back to pathname replacement.
549
+ - Creation is no-clobber. Once a created inode is published, a later failure cannot safely infer unlink ownership, so the destination and hidden stage are preserved and rollback fails closed as ambiguous.
550
+ - MCP and CLI apply share a fail-closed filesystem lock keyed by canonical `tsconfig.json` when they use the same state directory. It does not coordinate editors, NFS writers, writers using another configuration, or hostile external processes.
551
+ - Deterministic promise barriers prove the tested publication/rollback interleavings. They are a threat-boundary test seam, not a global atomicity or continuous-writer guarantee.
521
552
  - Receipt persistence runs before that lock is released. If receipt storage fails after source replacement, apply exits non-zero and reports that verified postimages may be present; retry recovers the receipt only when the complete workspace exactly matches the reviewed post-workspace fingerprint.
522
553
  - A hard process crash can leave a stale lock. Remove it only after inspecting its metadata and proving no apply is running; exact complete postimages can then recover the receipt, while partial or divergent state remains a conflict.
523
554
  - Source encoding support is UTF-8, with or without BOM. Unsupported encodings are rejected.
package/cordis.patch.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # Mounts the packaged `ast-mcp-server` stdio command through the official
4
4
  # `@deepseek-ai/dsh-mcp-client` bridge. Harness does not resolve MCP binaries
5
5
  # from PATH, so the entrypoint is resolved relative to the profile directory
6
- # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.13.0.tgz`
6
+ # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.14.0.tgz`
7
7
  # installs a locally packed package as a dependency.
8
8
  #
9
9
  # First-slice surface: reads + prepare + preview. Every apply path is denied
@@ -13,6 +13,8 @@
13
13
  # - the official bridge drops MCP tool.annotations, incl. readOnlyHint and
14
14
  # destructiveHint, so the guard enumerates tool names instead of hints;
15
15
  # - the stdio child runs outside the Harness sandbox.
16
+ # Harness native presentation reads text content, so this adapter explicitly
17
+ # requests a lossless canonical JSON projection while preserving structuredContent.
16
18
  - insert:
17
19
  - id: mcp-ast
18
20
  name: "@deepseek-ai/dsh-mcp-client"
@@ -27,5 +29,10 @@
27
29
  )
28
30
  env:
29
31
  AST_MCP_APPLY_GUARD: "deny"
32
+ AST_MCP_TEXT_PROJECTION: "canonical_json"
30
33
  cwd: !!js process.cwd()
34
+ toolCallTimeoutMs: !!js >-
35
+ JSON.parse(process.getBuiltinModule('node:fs').readFileSync(
36
+ new URL('node_modules/ast-mcp-server/package.json', baseUrl), 'utf8'
37
+ )).deepseekHarness.timeoutBudget.outerToolCallMs
31
38
  failOnStartupError: true
@@ -20,7 +20,7 @@ const BatchStepSchema = z
20
20
  .object({
21
21
  id: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/),
22
22
  tool: BatchToolSchema,
23
- input: z.record(z.unknown()).default({}),
23
+ input: z.record(z.string(), z.unknown()).default({}),
24
24
  foreach: z
25
25
  .object({ $ref: z.string().min(1) })
26
26
  .strict()
package/dist/server.js CHANGED
@@ -2,9 +2,20 @@ import { createRequire } from "node:module";
2
2
  import { McpServer, } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { PublicOperationalError } from "./services/public-errors.js";
4
4
  import { toolCatalog } from "./tools/catalog.js";
5
- import { createToolErrorContext, errorResult } from "./tools/result.js";
5
+ import { createToolErrorContext, errorResult, projectStructuredContentAsText, } from "./tools/result.js";
6
6
  const packageMetadata = createRequire(import.meta.url)("../package.json");
7
7
  export const PACKAGE_VERSION = packageMetadata.version;
8
+ function installStructuredContentTextProjection(server) {
9
+ const registerTool = server.registerTool.bind(server);
10
+ function projectedRegisterTool(name, config, callback) {
11
+ const projectedCallback = (async (...args) => {
12
+ const result = await Reflect.apply(callback, undefined, args);
13
+ return projectStructuredContentAsText(result, true);
14
+ });
15
+ return registerTool(name, config, projectedCallback);
16
+ }
17
+ server.registerTool = projectedRegisterTool;
18
+ }
8
19
  function installRuntimeActivityTracking(server, runtimeActivity) {
9
20
  const registerTool = server.registerTool.bind(server);
10
21
  function trackedRegisterTool(name, config, callback) {
@@ -25,6 +36,9 @@ export function createServer(options = {}) {
25
36
  });
26
37
  if (options.runtimeActivity)
27
38
  installRuntimeActivityTracking(server, options.runtimeActivity);
39
+ if (options.projectStructuredContentAsText === true) {
40
+ installStructuredContentTextProjection(server);
41
+ }
28
42
  if (options.denyApply === undefined) {
29
43
  toolCatalog.registerAll(server);
30
44
  }
@@ -0,0 +1,181 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { lstat, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ function sameStat(left, right) {
7
+ const fields = ["dev", "ino", "size", "mode", "mtimeNs", "ctimeNs"];
8
+ return fields.every((field) => left[field] === right[field]);
9
+ }
10
+ export async function snapshotHeldFile(handle) {
11
+ const before = await handle.stat({ bigint: true });
12
+ if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER))
13
+ throw new Error("Publication descriptor cannot be authenticated");
14
+ const bytes = await readFile(`/proc/self/fd/${handle.fd}`);
15
+ const after = await handle.stat({ bigint: true });
16
+ if (!after.isFile() || !sameStat(before, after))
17
+ throw new Error("Publication descriptor changed during read");
18
+ return {
19
+ dev: after.dev.toString(),
20
+ ino: after.ino.toString(),
21
+ sha256: createHash("sha256").update(bytes).digest("hex"),
22
+ mode: Number(after.mode & 511n),
23
+ };
24
+ }
25
+ function equal(left, right) {
26
+ return (left !== undefined &&
27
+ right !== undefined &&
28
+ left.dev === right.dev &&
29
+ left.ino === right.ino &&
30
+ left.sha256 === right.sha256 &&
31
+ left.mode === right.mode);
32
+ }
33
+ async function entry(directory, basename) {
34
+ const target = `/proc/self/fd/${directory.fd}/${basename}`;
35
+ let handle;
36
+ try {
37
+ handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
38
+ const snapshot = await snapshotHeldFile(handle);
39
+ const current = await lstat(target, { bigint: true });
40
+ return current.dev.toString() === snapshot.dev && current.ino.toString() === snapshot.ino
41
+ ? snapshot
42
+ : undefined;
43
+ }
44
+ catch (error) {
45
+ if (error.code === "ENOENT")
46
+ return undefined;
47
+ throw error;
48
+ }
49
+ finally {
50
+ await handle?.close();
51
+ }
52
+ }
53
+ export async function runPublicationPrimitive(executable, args, fds) {
54
+ await new Promise((resolve, reject) => {
55
+ const child = spawn(executable, args, {
56
+ env: { LANG: "C", LC_ALL: "C", PATH: "/usr/bin:/bin" },
57
+ shell: false,
58
+ stdio: ["ignore", "ignore", "pipe", ...fds],
59
+ windowsHide: true,
60
+ });
61
+ let failure;
62
+ let stderrBytes = 0;
63
+ const fail = (message, cause) => {
64
+ if (failure)
65
+ return;
66
+ failure = new Error(message, cause === undefined ? undefined : { cause });
67
+ child.kill("SIGKILL");
68
+ };
69
+ const timeout = setTimeout(() => fail("Publication primitive timed out"), 5_000);
70
+ child.stderr.on("data", (chunk) => {
71
+ stderrBytes += chunk.byteLength;
72
+ if (stderrBytes > 4_096)
73
+ fail("Publication primitive stderr exceeded limit");
74
+ });
75
+ child.on("error", (error) => fail("Publication primitive unavailable", error));
76
+ child.on("close", (code, signal) => {
77
+ clearTimeout(timeout);
78
+ if (failure)
79
+ reject(failure);
80
+ else if (code === 0 && signal === null)
81
+ resolve();
82
+ else
83
+ reject(new Error(`Publication primitive failed (${signal ?? code ?? "unknown"})`));
84
+ });
85
+ });
86
+ }
87
+ export const exchangeDirectoryEntries = (directory, left, right) => runPublicationPrimitive("/usr/bin/mv", ["--exchange", "--no-copy", "-T", "--", `/proc/self/fd/3/${left}`, `/proc/self/fd/3/${right}`], [directory.fd]);
88
+ export const linkHeldFile = (source, directory, basename) => runPublicationPrimitive("/usr/bin/ln", ["-L", "-T", "--", "/proc/self/fd/3", `/proc/self/fd/4/${basename}`], [source.fd, directory.fd]);
89
+ export async function removeOwnedPublicationEntry(directory, basename, expected) {
90
+ if (!equal(await entry(directory, basename), expected))
91
+ return false;
92
+ await rm(`/proc/self/fd/${directory.fd}/${basename}`);
93
+ return (await entry(directory, basename)) === undefined;
94
+ }
95
+ export async function rollbackOwnedCommit(token, hooks = {}) {
96
+ if (token.kind === "creation")
97
+ return { state: "ambiguous", phase: "rollback" };
98
+ await hooks.beforeRollback?.();
99
+ if (!equal(await entry(token.directory, token.destinationBasename), token.destinationIdentity) ||
100
+ !equal(await entry(token.directory, token.displacedBasename), token.displacedIdentity))
101
+ return { state: "ambiguous", phase: "rollback" };
102
+ await exchangeDirectoryEntries(token.directory, token.displacedBasename, token.destinationBasename).catch(() => undefined);
103
+ const restored = equal(await entry(token.directory, token.destinationBasename), token.displacedIdentity) &&
104
+ equal(await entry(token.directory, token.displacedBasename), token.destinationIdentity);
105
+ if (!restored)
106
+ return { state: "ambiguous", phase: "rollback" };
107
+ return { state: "rolled_back", reason: "conflict" };
108
+ }
109
+ export async function publishAuthenticated(plan) {
110
+ if (process.platform !== "linux")
111
+ return { state: "pre_effect", reason: "unsupported" };
112
+ await plan.beforePublish?.();
113
+ if (plan.kind === "creation") {
114
+ await linkHeldFile(plan.stage, plan.directory, plan.destinationBasename).catch(() => undefined);
115
+ const destination = await entry(plan.directory, plan.destinationBasename);
116
+ if (equal(destination, plan.stageIdentity))
117
+ return {
118
+ state: "committed",
119
+ token: {
120
+ kind: "creation",
121
+ directory: plan.directory,
122
+ destinationBasename: plan.destinationBasename,
123
+ destinationIdentity: plan.stageIdentity,
124
+ },
125
+ };
126
+ if (destination?.dev === plan.stageIdentity.dev && destination.ino === plan.stageIdentity.ino)
127
+ return { state: "ambiguous", phase: "commit" };
128
+ return { state: "pre_effect", reason: destination ? "conflict" : "unsupported" };
129
+ }
130
+ await exchangeDirectoryEntries(plan.directory, plan.stageBasename, plan.destinationBasename).catch(() => undefined);
131
+ const destination = await entry(plan.directory, plan.destinationBasename);
132
+ const displaced = await entry(plan.directory, plan.stageBasename);
133
+ if (equal(destination, plan.stageIdentity) && displaced) {
134
+ const token = {
135
+ kind: "replacement",
136
+ directory: plan.directory,
137
+ destinationBasename: plan.destinationBasename,
138
+ displacedBasename: plan.stageBasename,
139
+ destinationIdentity: destination,
140
+ displacedIdentity: displaced,
141
+ };
142
+ const held = plan.preimage && (await snapshotHeldFile(plan.preimage));
143
+ if (equal(displaced, plan.preimageIdentity) && equal(held, plan.preimageIdentity))
144
+ return { state: "committed", token };
145
+ return rollbackOwnedCommit(token, plan);
146
+ }
147
+ if (equal(destination, plan.preimageIdentity) && equal(displaced, plan.stageIdentity))
148
+ return { state: "pre_effect", reason: "unsupported" };
149
+ return { state: "ambiguous", phase: "commit" };
150
+ }
151
+ export async function probePublicationCapability(parent) {
152
+ const probe = path.join(parent, `.ast-publication-probe-${randomUUID()}`);
153
+ const handles = [];
154
+ try {
155
+ await mkdir(probe);
156
+ const directory = await open(probe, "r");
157
+ handles.push(directory);
158
+ for (const [name, bytes] of [
159
+ ["stage", "stage"],
160
+ ["replacement", "replacement"],
161
+ ])
162
+ await writeFile(path.join(probe, name), bytes);
163
+ const stage = await open(path.join(probe, "stage"), "r");
164
+ handles.push(stage);
165
+ const replacement = await open(path.join(probe, "replacement"), "r");
166
+ handles.push(replacement);
167
+ const stageIdentity = await snapshotHeldFile(stage);
168
+ const replacementIdentity = await snapshotHeldFile(replacement);
169
+ await linkHeldFile(stage, directory, "created");
170
+ if (!equal(await entry(directory, "created"), stageIdentity))
171
+ throw new Error("Link unsupported");
172
+ await exchangeDirectoryEntries(directory, "replacement", "created");
173
+ if (!equal(await entry(directory, "created"), replacementIdentity) ||
174
+ !equal(await entry(directory, "replacement"), stageIdentity))
175
+ throw new Error("Publication exchange unsupported");
176
+ }
177
+ finally {
178
+ await Promise.allSettled(handles.map((handle) => handle.close()));
179
+ await rm(probe, { recursive: true, force: true });
180
+ }
181
+ }
@@ -17,11 +17,7 @@ export function planCallSpines(root, edges, options) {
17
17
  const maxEdges = assertLimit(options.max_edges, 1, "max_edges");
18
18
  const accepted = edges.filter(exactCall);
19
19
  const freshness = options.freshness?.state ?? edges[0]?.freshness.state;
20
- const authorityState = freshness !== "fresh" || accepted.length !== edges.length
21
- ? "untrusted"
22
- : options.discovery_complete === false
23
- ? "incomplete"
24
- : "authoritative";
20
+ const trusted = freshness === "fresh" && accepted.length === edges.length;
25
21
  const reasons = new Set();
26
22
  const rootKey = endpointKey(root);
27
23
  const initial = { endpoint: root, endpoints: [root], relationshipIds: [], depth: 0 };
@@ -82,6 +78,15 @@ export function planCallSpines(root, edges, options) {
82
78
  endpoints: record.endpoints,
83
79
  relationship_ids: record.relationshipIds,
84
80
  }));
81
+ const reached = new Set(records.keys());
82
+ const relevantGap = (options.unfinished_gaps ?? []).some((gap) => options.direction === "outgoing"
83
+ ? reached.has(endpointKey(gap.source))
84
+ : gap.alternatives.some((alternative) => reached.has(endpointKey(alternative))));
85
+ const authorityState = !trusted
86
+ ? "untrusted"
87
+ : options.discovery_complete === false || relevantGap
88
+ ? "incomplete"
89
+ : "authoritative";
85
90
  const incomplete = authorityState !== "authoritative" || reasons.size > 0;
86
91
  return {
87
92
  root,
@@ -4,8 +4,9 @@ import { randomUUID } from "node:crypto";
4
4
  import { createInterface } from "node:readline";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
6
  import { fileURLToPath } from "node:url";
7
- import { decodeCompilerWorkerEnvelope, startCompilerWorker } from "./compiler-worker-protocol.js";
8
- import { createCompilerWorkerSpawnSpec, readRuntimePolicy, } from "./runtime-policy.js";
7
+ import { COMPILER_WORKER_MAX_FRAME_BYTES, decodeCompilerWorkerEnvelope, decodeCompilerWorkerResponse, isCompilerWorkerRequestIdWithinBudget, startCompilerWorker, } from "./compiler-worker-protocol.js";
8
+ import { emitConfiguredH03HostEvent } from "./h03-timeout-fixture.js";
9
+ import { createCompilerWorkerSpawnSpec, parseH03FixtureDescriptor, parseH05FixtureDescriptor, readRuntimePolicy, } from "./runtime-policy.js";
9
10
  import { emitCompilerWorkerEvent } from "./runtime-logger.js";
10
11
  import { PublicOperationalError, renderPublicError } from "./public-errors.js";
11
12
  export class CompilerWorkerHostError extends Error {
@@ -17,7 +18,7 @@ export class CompilerWorkerHostError extends Error {
17
18
  this.recovery = recovery;
18
19
  }
19
20
  }
20
- const bounded = (value) => Buffer.byteLength(JSON.stringify(value)) <= 256 * 1024;
21
+ const bounded = (value) => Buffer.byteLength(JSON.stringify(value)) <= COMPILER_WORKER_MAX_FRAME_BYTES;
21
22
  const within = (promise, ms) => Promise.race([
22
23
  promise,
23
24
  delay(ms, undefined, { ref: false }).then(() => {
@@ -34,7 +35,11 @@ function deferred() {
34
35
  return { promise, resolve, reject };
35
36
  }
36
37
  export function spawnCompilerWorkerProcess(options) {
37
- const spec = createCompilerWorkerSpawnSpec(options.workerEntryPath, options.environment);
38
+ const fixture = parseH03FixtureDescriptor(options.environment.AST_H03_FIXTURE);
39
+ const lifecycleFixture = parseH05FixtureDescriptor(options.environment.AST_H05_FIXTURE);
40
+ // prettier-ignore
41
+ const environment = fixture || lifecycleFixture ? { ...options.environment, ...(fixture ? { AST_H03_FIXTURE: JSON.stringify({ ...fixture, generation: options.generation }) } : {}), ...(lifecycleFixture ? { AST_H05_FIXTURE: JSON.stringify({ ...lifecycleFixture, generation: options.generation }) } : {}) } : options.environment;
42
+ const spec = createCompilerWorkerSpawnSpec(options.workerEntryPath, environment);
38
43
  if (!spec.ok || !spec.command)
39
44
  throw new CompilerWorkerHostError("startup");
40
45
  const child = spawn(spec.command, spec.args, {
@@ -86,17 +91,12 @@ export function spawnCompilerWorkerProcess(options) {
86
91
  snapshots.shift()?.resolve(decoded.value);
87
92
  });
88
93
  createInterface({ input: child.stdout }).on("line", (line) => {
89
- if (Buffer.byteLength(line) > 256 * 1024)
94
+ const decoded = decodeCompilerWorkerResponse(line);
95
+ if (!decoded.ok)
90
96
  return fail(new CompilerWorkerHostError("protocol"));
91
- let message;
92
- try {
93
- message = JSON.parse(line);
94
- }
95
- catch {
96
- return fail(new CompilerWorkerHostError("protocol"));
97
- }
98
- if (!("id" in message) || message.id === undefined)
97
+ if (decoded.kind === "notification")
99
98
  return;
99
+ const message = decoded.value;
100
100
  const request = pending.get(message.id);
101
101
  if (!request)
102
102
  return;
@@ -108,7 +108,10 @@ export function spawnCompilerWorkerProcess(options) {
108
108
  });
109
109
  });
110
110
  const write = (message, acknowledge = false) => {
111
- if (!bounded(message) || !child.stdin?.writable)
111
+ const requestIdWithinBudget = !("id" in message) ||
112
+ message.id === undefined ||
113
+ isCompilerWorkerRequestIdWithinBudget(message.id);
114
+ if (!requestIdWithinBudget || !bounded(message) || !child.stdin?.writable)
112
115
  return Promise.reject(new CompilerWorkerHostError("protocol"));
113
116
  options.beforeWrite?.(message);
114
117
  const writeSequence = ++writes, processed = acknowledge ? deferred() : undefined;
@@ -217,7 +220,7 @@ export class CompilerWorkerHost {
217
220
  return this.scheduleIdle(); this.parentAdmission = "closed"; const second = await child.snapshot(); if (child !== this.child || generation !== this.generation || sequence !== this.sequence || activity !== this.activity || !CompilerWorkerHost.quiescent(second)) {
218
221
  this.parentAdmission = "open";
219
222
  return this.scheduleIdle();
220
- } await this.stopChild(child, "requested"); emitCompilerWorkerEvent({ kind: "idle", generation }); this.parentAdmission = "open"; }
223
+ } await this.stopChild(child, "requested"); emitCompilerWorkerEvent({ kind: "idle", generation }); emitConfiguredH03HostEvent("recycled", generation); this.parentAdmission = "open"; }
221
224
  // prettier-ignore
222
225
  async stopChild(child, trigger) { const completion = child.shutdown(trigger); try {
223
226
  await within(completion, this.timeoutMs);
@@ -412,7 +415,24 @@ export async function runSupervisedStdioServer() {
412
415
  const rendered = ambiguous
413
416
  ? renderPublicError(new PublicOperationalError("AMBIGUOUS_APPLY", "The apply outcome is ambiguous."), ambiguous.correlation_id).envelope.error
414
417
  : undefined;
415
- process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32603, message: rendered?.message ?? "Compiler worker request failed.", ...(ambiguous ? { data: { code: "AMBIGUOUS_APPLY", ...ambiguous } } : {}) } })}\n`);
418
+ const responseId = isCompilerWorkerRequestIdWithinBudget(message.id) ? message.id : null;
419
+ const candidate = {
420
+ jsonrpc: "2.0",
421
+ id: responseId,
422
+ error: {
423
+ code: -32603,
424
+ message: rendered?.message ?? "Compiler worker request failed.",
425
+ ...(ambiguous ? { data: { code: "AMBIGUOUS_APPLY", ...ambiguous } } : {}),
426
+ },
427
+ };
428
+ const response = bounded(candidate)
429
+ ? candidate
430
+ : {
431
+ jsonrpc: "2.0",
432
+ id: null,
433
+ error: { code: -32603, message: "Compiler worker request failed." },
434
+ };
435
+ process.stdout.write(`${JSON.stringify(response)}\n`);
416
436
  }
417
437
  }
418
438
  };