From f7bbf7a0b64e93ea4b438bb789d2b0e6250c1a2d Mon Sep 17 00:00:00 2001 From: Stanislau Niadbailau Date: Mon, 13 Apr 2026 22:32:57 -0400 Subject: [PATCH 1/3] Use hosted MCP by default and harden deploy runtime paths --- .codex/config.toml | 7 +- .gitignore | 1 + .mcp.json | 8 ++ AGENTS.md | 2 +- README.md | 55 ++++++----- .../drr/DRR-0001-mcp-first-class-interface.md | 14 +-- docs/mcp-interface.md | 49 +++++----- manifest.json | 4 +- package.json | 4 +- scripts/prepare-deploy.sh | 30 ++++++ server.json | 5 +- src/mcp/tools.ts | 12 +-- src/runtime/path-resolution.ts | 92 +++++++++++++++++++ src/runtime/runtime.ts | 29 ++++-- tests/runtime-path-resolution.test.ts | 69 ++++++++++++++ 15 files changed, 292 insertions(+), 89 deletions(-) create mode 100644 .mcp.json create mode 100755 scripts/prepare-deploy.sh create mode 100644 src/runtime/path-resolution.ts create mode 100644 tests/runtime-path-resolution.test.ts diff --git a/.codex/config.toml b/.codex/config.toml index 723d4cb..8b2bb19 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,9 +1,4 @@ #:schema https://developers.openai.com/codex/config-schema.json [mcp_servers.fpf_memory] -command = "bun" -args = ["src/mastra/stdio.ts"] -cwd = "." -required = false -startup_timeout_sec = 15 -tool_timeout_sec = 60 +url = "https://fpf-memory-remote-20260414.server.mastra.cloud/api/mcp/fpf_memory/mcp" diff --git a/.gitignore b/.gitignore index 020a573..9c31523 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ output.txt *.db-* .mastra-project.json package-lock.json +src/mastra/public .DS_Store diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..5a87b7b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "fpf_memory": { + "type": "http", + "url": "https://fpf-memory-remote-20260414.server.mastra.cloud/api/mcp/fpf_memory/mcp" + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 6c4a5da..1c89eb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Public tools (deployed MCP surface): - `query_fpf_spec` for structured answer envelopes - `get_fpf_index_status` for runtime freshness checks -Expert tools (local stdio only, via `bun run mcp`): +Expert tools (local full-surface runtime only, via `FPF_MCP_SURFACE=full bun run mcp`): - `read_fpf_doc` for exact generated markdown pages - `trace_fpf_path` for retrieval evidence and provenance diff --git a/README.md b/README.md index f744f13..7eba43a 100644 --- a/README.md +++ b/README.md @@ -104,13 +104,19 @@ bun run mcp ## Run And Test MCP -Start the stdio MCP server: +Hosted/public MCP endpoint used by Codex by default: + +```text +https://fpf-memory-remote-20260414.server.mastra.cloud/api/mcp/fpf_memory/mcp +``` + +Optional local full-surface MCP server for development and expert tools: ```bash -bun run mcp +FPF_MCP_SURFACE=full bun run mcp ``` -Start the hosted Mastra runtime on the Hono engine: +Start the hosted Mastra runtime locally on the Hono engine: ```bash bun run start @@ -122,31 +128,16 @@ Decision record for this interface choice: - [DRR-0001: MCP As The First-Class Codex Interface](docs/drr/DRR-0001-mcp-first-class-interface.md) -For Codex registration: - -- Command: `bun` -- Arguments: `src/mastra/stdio.ts` -- Working directory: your local `fpf-memory` repo root +The DRR records the MCP-first boundary choice; the current Codex default is the hosted public MCP. Equivalent `~/.codex/config.toml` entry: ```toml [mcp_servers.fpf_memory] -command = "bun" -args = ["src/mastra/stdio.ts"] -cwd = "/absolute/path/to/fpf-memory" -required = false -startup_timeout_sec = 15 -tool_timeout_sec = 60 +url = "https://fpf-memory-remote-20260414.server.mastra.cloud/api/mcp/fpf_memory/mcp" ``` -This repo now also ships the same project-scoped configuration at `.codex/config.toml`. Once the project is trusted, Codex can load the `fpf_memory` server directly from the repo without copying the snippet into your user config. - -Local development can keep using the Bun shortcut: - -```bash -bun run mcp -``` +This repo ships the same project-scoped configuration at `.codex/config.toml` and `.mcp.json`. Once the project is trusted, Codex can load the hosted `fpf_memory` server directly from the repo. Recommended Codex tasks: @@ -154,13 +145,19 @@ Recommended Codex tasks: - structured query: `Use only the fpf_memory MCP server. Call query_fpf_spec with question: "What is an FPF pattern?"` - check runtime freshness: `Use only the fpf_memory MCP server. Call get_fpf_index_status` -Expert tasks (local stdio only): +Expert tasks (local full-surface runtime only): - read a generated page: `Use only the fpf_memory MCP server. Call read_fpf_doc with selector: "A.1.1"` - inspect retrieval evidence: `Use only the fpf_memory MCP server. Call trace_fpf_path with question: "How do U.RoleAssignment and U.BoundedContext connect?"` - rebuild the local index: `Use only the fpf_memory MCP server. Call refresh_fpf_index` -Smoke-test the same runtime surface locally before wiring it into Codex: +Start the local full-surface runtime before using expert tools: + +```bash +FPF_MCP_SURFACE=full bun run mcp +``` + +Smoke-test the local full-surface runtime before using expert tools or deploying changes: ```bash bun run cli -- status @@ -178,13 +175,13 @@ Run the end-to-end verification script for the real CLI, MCP stdio, and hosted H ./scripts/verify-runtime.sh ``` -The verification script also checks the direct stdio launcher (same entry as `bun run mcp`): +The verification script also checks the direct stdio launcher (same entry as `bun run mcp`; add `FPF_MCP_SURFACE=full` for expert-tool work): ```bash -bun src/mastra/stdio.ts +FPF_MCP_SURFACE=full bun src/mastra/stdio.ts ``` -This starts a long-running stdio server; for a manual smoke check, stop it with `Ctrl+C` after startup confirmation. +This starts a long-running stdio server; for a manual smoke check, stop it with `Ctrl+C` after startup confirmation. Omit `FPF_MCP_SURFACE=full` if you only want the public 3-tool surface. If this repo is registered as a Codex MCP server, restart Codex after changes and then test with a forced tool-use prompt such as: @@ -248,7 +245,9 @@ Call trace_fpf_path with: - `query_fpf_spec`: return the answer envelope with IDs, citations, constraints, and freshness metadata - `get_fpf_index_status`: inspect runtime freshness, artifact presence, and runtime configuration -### Expert tools (local stdio only) +### Expert tools (local full-surface runtime only) + +Set `FPF_MCP_SURFACE=full` on local stdio or local HTTP runtimes to expose these tools. The deployed server stays on the public 3-tool surface. - `refresh_fpf_index`: rebuild the local artifact set - `trace_fpf_path`: return deterministic retrieval evidence only @@ -257,7 +256,7 @@ Call trace_fpf_path with: - `inspect_fpf_anchor`: expand one anchor into raw anchor text plus owning node context - `expand_fpf_citations`: expand multiple citations into raw anchor text plus owning node context -Only `query_fpf_spec` and `ask_fpf` can use the optional synthesizer. All other MCP tools stay deterministic. Set `FPF_MCP_SURFACE=public` on the deployed server to restrict to public tools only. +Only `query_fpf_spec` and `ask_fpf` can use the optional synthesizer. All other MCP tools stay deterministic. Set `FPF_MCP_SURFACE=public` on the deployed server to restrict it to public tools only. ## Runtime behavior diff --git a/docs/drr/DRR-0001-mcp-first-class-interface.md b/docs/drr/DRR-0001-mcp-first-class-interface.md index 67b2abe..8b8104c 100644 --- a/docs/drr/DRR-0001-mcp-first-class-interface.md +++ b/docs/drr/DRR-0001-mcp-first-class-interface.md @@ -7,6 +7,8 @@ description: "Design-Rationale Record for the Codex-facing interface decision in Status: accepted for the bounded context `CodexAccess:LocalFPFSpecRuntime` +Update 2026-04-13: the default Codex registration path now uses the hosted public MCP URL. The local stdio transport remains available as an optional full-surface expert/dev path. + ## Problem frame `fpf_memory` needs a Codex-facing interface for grounded access to the local `FPF-spec.md` runtime. The repo already exposes a local MCP server, a Bun CLI, and a hosted Hono/Mastra runtime path, but the interface promise was implicit rather than recorded as an explicit decision. @@ -28,9 +30,9 @@ The decision includes these commitments: 1. The primary Codex integration surface is the `fpf_memory` MCP server. 2. The CLI remains an operator/debug surface, not the primary semantic boundary for agent use. -3. Hosted HTTP remains a transport/hosting option, not the first interface to optimize for in this repo slice. -4. The Codex registration path is documented and packaged around the stdio entry point: - `bun src/mastra/stdio.ts` +3. The current default Codex transport is the hosted public MCP URL. +4. The repo also keeps a local stdio entry point for optional full-surface expert/dev work: + `FPF_MCP_SURFACE=full bun src/mastra/stdio.ts` 5. This decision is recorded as a DRR outside the normative FPF core, consistent with `E.9`. ## Rationale @@ -44,7 +46,7 @@ This choice keeps the FPF layers separate. Why MCP, rather than the alternatives: - **Against CLI-first:** the CLI is useful for operators and smoke tests, but it is not the native tool-selection boundary for Codex. -- **Against custom HTTP-first:** Codex already supports MCP natively, so a bespoke API would add interface work without solving the current local integration problem. +- **Against custom HTTP-first:** Codex already supports MCP natively, so a bespoke API would add interface work without improving the MCP boundary itself. - **For MCP-first:** the repo already ships an MCP server, Codex natively supports MCP configuration, and the server contract matches the bounded need for grounded retrieval over local spec artifacts. This also follows FPF boundary discipline: @@ -63,14 +65,14 @@ Positive consequences: - Codex setup, packaging metadata, and verification can all align around a single published interface decision. - Future work can distinguish between: - boundary choice: MCP-first - - transport choice: stdio now, HTTP optional later + - transport choice: hosted/public by default, stdio optional for local expert work - surface-shape work: discovery, browse/search, tools/resources/prompts Trade-offs and follow-up consequences: - A tool-only MCP surface is still heavier than ideal for first-pass discovery, so later work should improve discovery without changing this decision. - Documentation now has one more artifact to keep current; that is acceptable because the DRR is the durable rationale carrier. -- If the bounded context changes from local Codex use to a hosted multi-tenant product, a later DRR may designate HTTP as an additional first-class external boundary for that different scope. +- If the bounded context changes again, a later DRR can further refine hosted/public vs local expert transport choices without overturning the MCP-first boundary. References: diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 254a6c5..6e8e662 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -1,17 +1,17 @@ --- title: "MCP Interface" -description: "Spec-oriented interface contract for the local FPF stdio MCP server." +description: "Spec-oriented interface contract for the hosted and local FPF MCP surfaces." --- # MCP Interface -This page documents the public MCP surface implemented by `fpf_memory`. +This page documents the MCP surfaces implemented by `fpf_memory`. Decision record: - [DRR-0001: MCP As The First-Class Codex Interface](/drr/DRR-0001-mcp-first-class-interface/) -The runtime itself is compiler-backed and local: +The runtime itself is compiler-backed and local to `FPF-spec.md`: - authored source: `FPF-spec.md` - runtime artifacts: `.runtime/fpf-index/*` @@ -20,38 +20,30 @@ The runtime itself is compiler-backed and local: ## Transport -- stdio (local): `bun run mcp` -- HTTP (local): `http://localhost:4111/api/mcp/fpf_memory/mcp` via `mastra dev` +- hosted/public (default Codex path): `https://fpf-memory-remote-20260414.server.mastra.cloud/api/mcp/fpf_memory/mcp` +- stdio (local expert/dev path): `FPF_MCP_SURFACE=full bun run mcp` +- HTTP (local dev path): `http://localhost:4111/api/mcp/fpf_memory/mcp` via `mastra dev` - server name: `fpf_memory` - protocol version: `2024-11-05` -Both stdio and HTTP default to the public tool surface (3 tools). Set `FPF_MCP_SURFACE=full` for all 9 tools. +The hosted server exposes only the 3 public tools. Local stdio and local HTTP default to the same public surface; set `FPF_MCP_SURFACE=full` to expose all 9 tools for local expert work. ## Codex Setup -Codex desktop app fields: - -- command: `bun` -- arguments: `src/mastra/stdio.ts` -- working directory: absolute path to the local repo root - -Equivalent `~/.codex/config.toml` entry: +Default `~/.codex/config.toml` entry: ```toml [mcp_servers.fpf_memory] -command = "bun" -args = ["src/mastra/stdio.ts"] -cwd = "/absolute/path/to/fpf-memory" -required = false -startup_timeout_sec = 15 -tool_timeout_sec = 60 +url = "https://fpf-memory-remote-20260414.server.mastra.cloud/api/mcp/fpf_memory/mcp" ``` -This repo also ships the same project-scoped configuration at `.codex/config.toml`. Codex will load that file after the project is trusted. +This repo ships the same hosted configuration at `.codex/config.toml` and `.mcp.json`. Codex will load that file after the project is trusted. + +For temporary local expert work, point a client at `src/mastra/stdio.ts` and set `FPF_MCP_SURFACE=full`. ## Tool Catalog -### Public tools (default surface) +### Public tools (hosted default surface) #### `ask_fpf` @@ -63,13 +55,13 @@ Answer a question with deterministic grounding, citations, constraints, and fres #### `get_fpf_index_status` -Report whether the local index exists, whether it is fresh against the current source hash, and which artifacts are present. +Report whether the current runtime index exists, whether it is fresh against the current source hash, and which artifacts are present. -### Expert tools (FPF_MCP_SURFACE=full) +### Expert tools (local full-surface runtime only) #### `refresh_fpf_index` -Build or rebuild the local vectorless index from `FPF-spec.md` and persist the runtime artifact set under `.runtime/fpf-index/`. +Build or rebuild the compiler-backed vectorless index from `FPF-spec.md` and persist the runtime artifact set under `.runtime/fpf-index/`. #### `trace_fpf_path` @@ -103,12 +95,13 @@ Static routes mirror those pages under `/generated/**` with clean URLs and `.htm ## Verification -Typical local checks: +Typical checks: ```bash bun run check bun run test -bun run docs:build -bun run cli -- read-doc --selector "A.1.1" -bun run mcp +curl -X POST https://fpf-memory-remote-20260414.server.mastra.cloud/api/mcp/fpf_memory/tools/get_fpf_index_status/execute \ + -H 'content-type: application/json' \ + -d '{"data":{}}' +FPF_MCP_SURFACE=full bun run mcp ``` diff --git a/manifest.json b/manifest.json index 4c776da..deb1641 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "fpf_memory", "version": "1.0.0", - "description": "Local vectorless FPF-spec runtime with MCP tools for answers, structured queries, and status.", + "description": "Compiler-backed FPF-spec runtime with hosted public MCP tools plus an optional local full-surface expert runtime.", "tools": { "public": [ "ask_fpf", @@ -19,7 +19,7 @@ }, "transport": ["stdio", "http"], "runtime": { - "bun": "bun src/mastra/stdio.ts" + "bun": "FPF_MCP_SURFACE=full bun src/mastra/stdio.ts" }, "http": { "path": "/api/mcp/fpf_memory/mcp" diff --git a/package.json b/package.json index f5623fa..2db74bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fpf-memory", "version": "1.0.0", - "description": "Bun-first local vectorless FPF spec runtime exposed through Mastra MCP surfaces with a Hono server engine.", + "description": "Bun-first compiler-backed FPF spec runtime exposed through hosted and local Mastra MCP surfaces with a Hono server engine.", "private": false, "packageManager": "bun@1.3.5", "bin": "dist/stdio.js", @@ -14,6 +14,8 @@ "build": "bun build ./src/cli.ts ./src/server.ts --outdir dist --target bun", "build:mcp": "tsup src/mastra/stdio.ts --format esm,cjs --out-dir dist --no-splitting && node -e \"const fs=require('node:fs');const path='dist/stdio.js';const data=fs.readFileSync(path,'utf8');const shebang='#!/usr/bin/env node';if(!data.startsWith(shebang))fs.writeFileSync(path, shebang + '\\\\n' + data);\" && chmod +x dist/stdio.js", "start": "bun src/server.ts", + "predeploy": "bash scripts/prepare-deploy.sh", + "deploy": "bun run predeploy && npx mastra build && npx mastra server deploy", "lint": "rslint --type-check src tests scripts/generate-docs.ts *.config.ts", "check": "tsc --noEmit", "cli": "bun src/cli.ts", diff --git a/scripts/prepare-deploy.sh b/scripts/prepare-deploy.sh new file mode 100755 index 0000000..68e7622 --- /dev/null +++ b/scripts/prepare-deploy.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Stage the two files the deployed runtime actually reads: +# 1. FPF-spec.md — source hash for freshness check +# 2. snapshot.json — compiled index (the only artifact loadSnapshot() reads) +# +# The other 6 artifact files (index-map, pattern-graph, …) are +# write-only debugging output — never loaded back by the runtime. +# +# Usage: bash scripts/prepare-deploy.sh +# bun run deploy + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PUBLIC="$ROOT/src/mastra/public" +INDEX="$ROOT/.runtime/fpf-index" + +# 1. Ensure the snapshot exists locally +if [ ! -f "$INDEX/snapshot.json" ]; then + echo "Snapshot not found — building index..." + bun "$ROOT/src/cli.ts" refresh-index +fi + +# 2. Stage only what the runtime reads +mkdir -p "$PUBLIC/.runtime/fpf-index" +cp "$ROOT/FPF-spec.md" "$PUBLIC/FPF-spec.md" +cp "$INDEX/snapshot.json" "$PUBLIC/.runtime/fpf-index/snapshot.json" + +echo "Staged into src/mastra/public/:" +du -sh "$PUBLIC/FPF-spec.md" "$PUBLIC/.runtime/fpf-index/snapshot.json" diff --git a/server.json b/server.json index 440e517..7490536 100644 --- a/server.json +++ b/server.json @@ -1,13 +1,14 @@ { "name": "fpf_memory", "version": "1.0.0", - "description": "Local vectorless FPF-spec runtime for MCP clients.", + "description": "Optional local full-surface FPF MCP runtime for expert clients.", "transport": "stdio", "command": "bun", "cwd": ".", "args": ["src/mastra/stdio.ts"], "env": { "FPF_SPEC_SOURCE_PATH": "FPF-spec.md", - "FPF_RUNTIME_ARTIFACT_DIR": ".runtime/fpf-index" + "FPF_RUNTIME_ARTIFACT_DIR": ".runtime/fpf-index", + "FPF_MCP_SURFACE": "full" } } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 04a4b7f..9375b7b 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -29,7 +29,7 @@ const DEFAULT_QUERY_MODE: AnswerMode = 'verbose'; export const refreshFpfIndexTool = createTool({ id: 'refresh_fpf_index', description: - 'Build or rebuild the local vectorless FPF index from FPF-spec.md and persist the artifact set.', + 'Build or rebuild the compiler-backed vectorless FPF index from FPF-spec.md and persist the artifact set.', inputSchema: refreshFpfIndexInputSchema, outputSchema: buildAuditSchema, execute: async ({ force }) => runtime.refresh(force ?? false), @@ -38,7 +38,7 @@ export const refreshFpfIndexTool = createTool({ export const queryFpfSpecTool = createTool({ id: 'query_fpf_spec', description: - 'Answer questions against the local vectorless FPF runtime with auditable IDs, citations, constraints, and freshness metadata.', + 'Answer questions against the compiler-backed vectorless FPF runtime with auditable IDs, citations, constraints, and freshness metadata.', inputSchema: queryFpfSpecInputSchema, outputSchema: queryResultSchema, execute: async ({ question, mode, forceRefresh, sessionId }) => @@ -48,7 +48,7 @@ export const queryFpfSpecTool = createTool({ export const askFpfTool = createTool({ id: 'ask_fpf', description: - 'Return an FPF answer in markdown with grounding metadata using the local vectorless runtime.', + 'Return an FPF answer in markdown with grounding metadata using the compiler-backed vectorless runtime.', inputSchema: askFpfInputSchema, outputSchema: askFpfResultSchema, execute: async ({ question, mode, forceRefresh, sessionId }) => { @@ -65,7 +65,7 @@ export const askFpfTool = createTool({ export const getFpfIndexStatusTool = createTool({ id: 'get_fpf_index_status', description: - 'Inspect whether the local FPF index exists, whether it is fresh against the current source hash, and which artifacts are present.', + 'Inspect whether the current FPF runtime index exists, whether it is fresh against the current source hash, and which artifacts are present.', inputSchema: getFpfIndexStatusInputSchema, outputSchema: runtimeStatusSchema, execute: async () => runtime.status(), @@ -128,7 +128,7 @@ export const fpfPublicTools = { get_fpf_index_status: getFpfIndexStatusTool, } as const; -/** Expert/debug tools — local use only. */ +/** Expert/debug tools — full-surface runtime only. */ export const fpfExpertTools = { refresh_fpf_index: refreshFpfIndexTool, trace_fpf_path: traceFpfPathTool, @@ -138,7 +138,7 @@ export const fpfExpertTools = { expand_fpf_citations: expandFpfCitationsTool, } as const; -/** All tools — used by local stdio and Hono server. */ +/** All tools — used by the full-surface MCP runtime. */ export const fpfMcpTools = { ...fpfPublicTools, ...fpfExpertTools, diff --git a/src/runtime/path-resolution.ts b/src/runtime/path-resolution.ts new file mode 100644 index 0000000..2448747 --- /dev/null +++ b/src/runtime/path-resolution.ts @@ -0,0 +1,92 @@ +import { statSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export type RuntimePathKind = 'file' | 'directory' | 'any'; + +export interface RuntimePathResolutionOptions { + cwd?: string; + moduleUrl?: string; + fallbackRoot?: string; + kind?: RuntimePathKind; +} + +export interface ResolvedRuntimePath { + path: string; + root: string; + existed: boolean; +} + +export function resolveRuntimePath( + rawPath: string, + options: RuntimePathResolutionOptions = {}, +): ResolvedRuntimePath { + const trimmedPath = rawPath.trim(); + const kind = options.kind ?? 'any'; + + if (isAbsolute(trimmedPath)) { + const absolutePath = resolve(trimmedPath); + return { + path: absolutePath, + root: kind === 'directory' ? absolutePath : dirname(absolutePath), + existed: pathMatchesKind(absolutePath, kind), + }; + } + + const discoveryRoots = unique([ + ...ancestorRoots(resolve(options.cwd ?? process.cwd())), + ...ancestorRoots(dirname(fileURLToPath(options.moduleUrl ?? import.meta.url))), + ]); + + for (const root of discoveryRoots) { + const candidate = resolve(root, trimmedPath); + if (pathMatchesKind(candidate, kind)) { + return { + path: candidate, + root, + existed: true, + }; + } + } + + const fallbackRoot = resolve(options.fallbackRoot ?? discoveryRoots[0] ?? process.cwd()); + return { + path: resolve(fallbackRoot, trimmedPath), + root: fallbackRoot, + existed: false, + }; +} + +function ancestorRoots(startPath: string): string[] { + const roots: string[] = []; + let current = resolve(startPath); + + while (true) { + roots.push(current); + const parent = dirname(current); + if (parent === current) { + return roots; + } + current = parent; + } +} + +function pathMatchesKind(path: string, kind: RuntimePathKind): boolean { + try { + const stats = statSync(path); + switch (kind) { + case 'file': + return stats.isFile(); + case 'directory': + return stats.isDirectory(); + default: + return true; + } + } catch { + return false; + } +} + +function unique(values: string[]): string[] { + return Array.from(new Set(values)); +} diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index c426391..11c5383 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -10,6 +10,7 @@ import { import { compileFpfSource } from './compiler.js'; import { createSynthesizerFromEnv } from './lm-studio-synthesizer.js'; import { QueryEngine } from './query-engine.js'; +import { resolveRuntimePath } from './path-resolution.js'; import { SessionCache, type RetrievalSessionState, @@ -44,14 +45,19 @@ export class FpfRuntime { private readonly sessionCache: SessionCache; constructor(options: FpfRuntimeOptions = {}) { - this.sourcePath = resolve( - process.cwd(), - options.sourcePath ?? process.env.FPF_SPEC_SOURCE_PATH ?? DEFAULT_SOURCE_PATH, - ); - this.artifactDir = resolve( - process.cwd(), - options.artifactDir ?? process.env.FPF_RUNTIME_ARTIFACT_DIR ?? DEFAULT_ARTIFACT_DIR, - ); + const sourcePath = options.sourcePath ?? process.env.FPF_SPEC_SOURCE_PATH ?? DEFAULT_SOURCE_PATH; + const sourceResolution = resolveRuntimePath(sourcePath, { + kind: 'file', + }); + const artifactDir = + options.artifactDir ?? process.env.FPF_RUNTIME_ARTIFACT_DIR ?? DEFAULT_ARTIFACT_DIR; + const artifactResolution = resolveRuntimePath(artifactDir, { + kind: 'directory', + fallbackRoot: sourceResolution.root, + }); + + this.sourcePath = sourceResolution.path; + this.artifactDir = artifactResolution.path; this.artifactPaths = Object.fromEntries( Object.entries(ARTIFACT_FILENAMES).map(([key, filename]) => [ key, @@ -191,7 +197,12 @@ export class FpfRuntime { } async status(): Promise { - const existingSnapshot = await this.loadSnapshot(); + let existingSnapshot = await this.loadSnapshot(); + if (!existingSnapshot) { + await this.refresh(false).catch(() => undefined); + existingSnapshot = await this.loadSnapshot(); + } + const currentSourceHash = await hashFile(this.sourcePath); return { sourcePath: this.sourcePath, diff --git a/tests/runtime-path-resolution.test.ts b/tests/runtime-path-resolution.test.ts new file mode 100644 index 0000000..62f49ac --- /dev/null +++ b/tests/runtime-path-resolution.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { resolveRuntimePath } from '../src/runtime/path-resolution.js'; + +const tempRoots: string[] = []; + +describe('resolveRuntimePath', () => { + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + it('falls back to the bundled module location when cwd does not contain staged assets', async () => { + const root = await createTempRoot('fpf-runtime-paths-'); + const appRoot = resolve(root, 'app'); + const bundleRoot = resolve(appRoot, '.mastra/output'); + const sourcePath = resolve(bundleRoot, 'FPF-spec.md'); + + await mkdir(dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, '# staged source\n'); + + const resolved = resolveRuntimePath('FPF-spec.md', { + cwd: appRoot, + kind: 'file', + moduleUrl: pathToFileURL(resolve(bundleRoot, 'index.mjs')).href, + }); + + expect(resolved.path).toBe(sourcePath); + expect(resolved.root).toBe(bundleRoot); + expect(resolved.existed).toBe(true); + }); + + it('uses the discovered source root as the artifact fallback root', async () => { + const root = await createTempRoot('fpf-runtime-artifacts-'); + const appRoot = resolve(root, 'app'); + const bundleRoot = resolve(appRoot, '.mastra/output'); + const sourcePath = resolve(bundleRoot, 'FPF-spec.md'); + + await mkdir(dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, '# staged source\n'); + + const sourceResolution = resolveRuntimePath('FPF-spec.md', { + cwd: appRoot, + kind: 'file', + moduleUrl: pathToFileURL(resolve(bundleRoot, 'index.mjs')).href, + }); + + const artifactResolution = resolveRuntimePath('.runtime/fpf-index', { + cwd: appRoot, + kind: 'directory', + fallbackRoot: sourceResolution.root, + moduleUrl: pathToFileURL(resolve(bundleRoot, 'index.mjs')).href, + }); + + expect(artifactResolution.path).toBe(resolve(bundleRoot, '.runtime/fpf-index')); + expect(artifactResolution.root).toBe(bundleRoot); + expect(artifactResolution.existed).toBe(false); + }); +}); + +async function createTempRoot(prefix: string): Promise { + const root = await mkdtemp(resolve(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} From 3536a1ca6a3407298108c8409179df091f84a555 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:48:49 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20CLI=20command,=20path=20resolution,=20deploy=20fres?= =?UTF-8?q?hness,=20error=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/prepare-deploy.sh: fix CLI subcommand (refresh-index → refresh) - scripts/prepare-deploy.sh: always refresh before staging to prevent stale snapshots - src/runtime/path-resolution.ts: use dirname(absolutePath) consistently for root - src/runtime/path-resolution.ts: reject empty/blank rawPath with descriptive error - src/runtime/runtime.ts: log swallowed refresh errors in status() for debuggability Co-Authored-By: Stanislau --- scripts/prepare-deploy.sh | 8 +++----- src/runtime/path-resolution.ts | 5 ++++- src/runtime/runtime.ts | 5 ++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/prepare-deploy.sh b/scripts/prepare-deploy.sh index 68e7622..e747330 100755 --- a/scripts/prepare-deploy.sh +++ b/scripts/prepare-deploy.sh @@ -15,11 +15,9 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" PUBLIC="$ROOT/src/mastra/public" INDEX="$ROOT/.runtime/fpf-index" -# 1. Ensure the snapshot exists locally -if [ ! -f "$INDEX/snapshot.json" ]; then - echo "Snapshot not found — building index..." - bun "$ROOT/src/cli.ts" refresh-index -fi +# 1. Rebuild snapshot so staged artifact matches current source +echo "Refreshing index snapshot..." +bun "$ROOT/src/cli.ts" refresh # 2. Stage only what the runtime reads mkdir -p "$PUBLIC/.runtime/fpf-index" diff --git a/src/runtime/path-resolution.ts b/src/runtime/path-resolution.ts index 2448747..d70106d 100644 --- a/src/runtime/path-resolution.ts +++ b/src/runtime/path-resolution.ts @@ -22,13 +22,16 @@ export function resolveRuntimePath( options: RuntimePathResolutionOptions = {}, ): ResolvedRuntimePath { const trimmedPath = rawPath.trim(); + if (trimmedPath.length === 0) { + throw new Error('Runtime path must not be empty'); + } const kind = options.kind ?? 'any'; if (isAbsolute(trimmedPath)) { const absolutePath = resolve(trimmedPath); return { path: absolutePath, - root: kind === 'directory' ? absolutePath : dirname(absolutePath), + root: dirname(absolutePath), existed: pathMatchesKind(absolutePath, kind), }; } diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 11c5383..e1cf906 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -199,7 +199,10 @@ export class FpfRuntime { async status(): Promise { let existingSnapshot = await this.loadSnapshot(); if (!existingSnapshot) { - await this.refresh(false).catch(() => undefined); + await this.refresh(false).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[FpfRuntime.status] refresh(false) failed: ${message}`); + }); existingSnapshot = await this.loadSnapshot(); } From f60c747d3c6fd4827cac51daf3fb0bbb0db7f997 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:00:46 +0000 Subject: [PATCH 3/3] fix(runtime): resolve artifactDir against source root and validate snapshot freshness in status() - Pre-compute absolute artifact path via resolve(sourceResolution.root, artifactDir) before passing to resolveRuntimePath, preventing ancestor-walk from binding to unrelated .runtime/fpf-index directories in parent checkouts. - Extend status() to also trigger refresh when snapshot exists but fails snapshotNeedsRebuild() (incompatible schema), not just when snapshot is missing. - Update fresh computation to include snapshotNeedsRebuild() check, ensuring status never reports fresh:true for structurally incompatible snapshots. Co-Authored-By: Stanislau --- src/runtime/runtime.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index e1cf906..54c1db7 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -51,10 +51,12 @@ export class FpfRuntime { }); const artifactDir = options.artifactDir ?? process.env.FPF_RUNTIME_ARTIFACT_DIR ?? DEFAULT_ARTIFACT_DIR; - const artifactResolution = resolveRuntimePath(artifactDir, { - kind: 'directory', - fallbackRoot: sourceResolution.root, - }); + const artifactResolution = resolveRuntimePath( + resolve(sourceResolution.root, artifactDir), + { + kind: 'directory', + }, + ); this.sourcePath = sourceResolution.path; this.artifactDir = artifactResolution.path; @@ -198,7 +200,7 @@ export class FpfRuntime { async status(): Promise { let existingSnapshot = await this.loadSnapshot(); - if (!existingSnapshot) { + if (!existingSnapshot || snapshotNeedsRebuild(existingSnapshot)) { await this.refresh(false).catch((error) => { const message = error instanceof Error ? error.message : String(error); console.warn(`[FpfRuntime.status] refresh(false) failed: ${message}`); @@ -213,7 +215,10 @@ export class FpfRuntime { builtAt: existingSnapshot?.builtAt, snapshotExists: Boolean(existingSnapshot), currentSourceHash, - fresh: existingSnapshot?.sourceHash === currentSourceHash, + fresh: + existingSnapshot != null && + !snapshotNeedsRebuild(existingSnapshot) && + existingSnapshot.sourceHash === currentSourceHash, compilerMode: 'local_vectorless', artifacts: await this.getArtifactPresence(), synthesizer: this.synthesizer?.describe