import { isAbsolute, normalize } from "node:path"; import { type AmqCliAdapter, type AmqExitCategory, } from "./amq-cli-adapter.js"; export const PI_AMQ_BINDING_ENTRY_TYPE = "pi-amq-binding/v1" as const; const BINDING_KEYS = [ "handle", "root", "rootId", "baseRoot", "baseRootId", "session", "project", "amqVersion", ] as const; const FLAG_KEYS = ["handle", "root", "session", "mode"] as const; const INHERITED_KEYS = [ "AM_ROOT", "AM_ROOT_ID", "AM_BASE_ROOT", "AM_BASE_ROOT_ID", "AM_SESSION", "AM_ME", ] as const; export interface BindingProjectionV1 { readonly handle: string; readonly root: string; readonly rootId: string; readonly baseRoot: string; readonly baseRootId: string; readonly session: string; readonly project: string; readonly amqVersion: string; } export interface ExplicitBindingFlags { readonly handle?: string; readonly root?: string; readonly session?: string; /** Phase 1 carries this value but does not invent mode semantics absent from the design. */ readonly mode?: string; } export interface ResolveBindingInput { readonly flags?: ExplicitBindingFlags; readonly environment?: Readonly; readonly persistedBinding?: unknown; } export type BindingSource = "flags" | "environment" | "persisted"; export type DormantReason = | "unbound" | "invalid-flags" | "incomplete-environment" | "invalid-environment" | "invalid-persisted-binding" | "authority-mismatch" | "context-mismatch" | "verification-failed" | "invalid-env-output" | "verification-mismatch" | "stale-persisted-binding"; export type BindingResolution = | { readonly kind: "bound"; readonly source: BindingSource; readonly binding: BindingProjectionV1; readonly requestedMode?: string; } | { readonly kind: "dormant"; readonly reason: DormantReason; readonly diagnostic: string; readonly action: "/amq bind"; readonly exitCategory?: AmqExitCategory; }; interface CandidateBinding { readonly source: BindingSource; readonly handle: string; readonly root: string; readonly session?: string; readonly expected?: BindingProjectionV1; readonly requestedMode?: string; } interface AmqEnvV1 { readonly amqVersion: string; readonly root: string; readonly rootId: string; readonly baseRoot: string; readonly baseRootId: string; readonly session: string; readonly inSession: boolean; readonly me: string; readonly project: string; } interface ParsedFlags { readonly candidate?: CandidateBinding; readonly requestedMode?: string; } interface ParsedEnvironment { readonly candidate?: CandidateBinding; } /** Resolves authority without reading AMQ-owned filesystem paths. */ export class BindingResolver { readonly #adapter: Pick; constructor(adapter: Pick) { this.#adapter = adapter; } async resolve(input: ResolveBindingInput = {}): Promise { const environment = input.environment ?? process.env; let flags: ParsedFlags; try { flags = parseFlags(input.flags); } catch (error) { return dormant("invalid-flags", toError(error).message); } let inherited: ParsedEnvironment; try { inherited = parseInheritedEnvironment(environment); } catch (error) { const message = toError(error).message; const reason = message.startsWith("Incomplete inherited AMQ context") ? "incomplete-environment" : "invalid-environment"; return dormant(reason, message); } let persisted: BindingProjectionV1 | undefined; if (input.persistedBinding !== undefined) { try { persisted = parseBindingProjectionV1(input.persistedBinding); } catch (error) { return dormant("invalid-persisted-binding", toError(error).message); } } if (flags.candidate) { if (inherited.candidate && routingConflict(flags.candidate, inherited.candidate.expected)) { return dormant( "authority-mismatch", "Explicit AMQ flags conflict with the inherited AMQ pin; relaunch or confirm the change with /amq bind", ); } if (persisted && routingConflict(flags.candidate, persisted)) { return dormant( "authority-mismatch", "Explicit AMQ flags differ from the persisted pi-amq-binding/v1 projection", ); } const expected = persisted ?? inherited.candidate?.expected; return this.#verify( withExpected(flags.candidate, expected), environment, ); } if (inherited.candidate) { if (persisted && !sameBindingAuthority(inherited.candidate.expected, persisted)) { return dormant( "authority-mismatch", "Inherited AMQ context differs from the persisted pi-amq-binding/v1 projection", ); } return this.#verify( withMode(inherited.candidate, flags.requestedMode), environment, ); } if (persisted) { return this.#verify( { source: "persisted", handle: persisted.handle, root: persisted.root, session: persisted.session, expected: persisted, ...(flags.requestedMode ? { requestedMode: flags.requestedMode } : {}), }, environment, ); } return dormant("unbound", "No explicit, inherited, or persisted AMQ binding is available"); } async #verify( candidate: CandidateBinding, environment: Readonly, ): Promise { const argv = candidate.source === "environment" ? ["env"] : ["env", "--root", candidate.root, "--me", candidate.handle]; const result = await this.#adapter.runJson(argv, { env: commandEnvironment(environment), parse: parseAmqEnvV1, }); if (!result.ok || !result.json) { if (result.category === "context-mismatch") { return dormant( "context-mismatch", literalDiagnostic(result.stderr, "AMQ refused the current root/session pin"), result.category, ); } if (result.category === "invalid-json") { return dormant( "invalid-env-output", result.jsonError?.message ?? "AMQ env returned invalid JSON", result.category, ); } return dormant( "verification-failed", literalDiagnostic(result.stderr, `amq env verification failed (${result.category})`), result.category, ); } const live = result.json; const mismatches = candidateMismatches(candidate, live); if (mismatches.length > 0) { const reason = candidate.source === "persisted" ? "stale-persisted-binding" : "verification-mismatch"; return dormant( reason, `Live AMQ context mismatch: ${mismatches.join(", ")}`, ); } const mailbox = await this.#adapter.runJson( [ "list", "--root", live.root, "--me", live.me, "--new", "--limit", "1", ], { env: commandEnvironment(environment), parse: parseMailboxList, }, ); if (!mailbox.ok) { return dormant( mailbox.category === "context-mismatch" ? "context-mismatch" : "verification-failed", literalDiagnostic(mailbox.stderr, `AMQ mailbox verification failed (${mailbox.category})`), mailbox.category, ); } return { kind: "bound", source: candidate.source, binding: projectionFromEnv(live), ...(candidate.requestedMode ? { requestedMode: candidate.requestedMode } : {}), }; } } export function parseBindingProjectionV1(value: unknown): BindingProjectionV1 { const object = record(value, "pi-amq-binding/v1"); rejectUnknownKeys(object, BINDING_KEYS, "pi-amq-binding/v1"); const binding: BindingProjectionV1 = { handle: requiredString(object, "handle"), root: requiredAbsolutePath(object, "root"), rootId: requiredString(object, "rootId"), baseRoot: requiredAbsolutePath(object, "baseRoot"), baseRootId: requiredString(object, "baseRootId"), session: stringField(object, "session"), project: stringField(object, "project"), amqVersion: requiredString(object, "amqVersion"), }; return binding; } function parseAmqEnvV1(value: unknown): AmqEnvV1 { const object = record(value, "amq env --json"); if (numberField(object, "schema_version") !== 1) { throw new Error("amq env schema_version must be 1"); } const peers = record(object.peers, "amq env peers"); for (const [peer, path] of Object.entries(peers)) { if (typeof path !== "string") throw new Error(`amq env peers.${peer} must be a string`); } const output: AmqEnvV1 = { amqVersion: requiredString(object, "amq_version"), root: requiredAbsolutePath(object, "root"), rootId: requiredString(object, "root_id"), baseRoot: requiredAbsolutePath(object, "base_root"), baseRootId: requiredString(object, "base_root_id"), session: stringField(object, "session_name"), inSession: booleanField(object, "in_session"), me: requiredString(object, "me"), project: stringField(object, "project"), }; requiredString(object, "root_source"); if (output.inSession !== (output.session.length > 0)) { throw new Error("amq env in_session and session_name are inconsistent"); } return output; } function parseMailboxList(value: unknown): readonly unknown[] { if (!Array.isArray(value)) throw new Error("amq list --json must return an array"); return value; } function parseFlags(value: ExplicitBindingFlags | undefined): ParsedFlags { if (!value) return {}; const object = value as Record; rejectUnknownKeys(object, FLAG_KEYS, "explicit AMQ flags"); const requestedMode = optionalNonEmptyString(object.mode, "mode"); const hasRoutingFlag = object.handle !== undefined || object.root !== undefined || object.session !== undefined; if (!hasRoutingFlag) return requestedMode ? { requestedMode } : {}; const handle = optionalNonEmptyString(object.handle, "handle"); const root = optionalNonEmptyString(object.root, "root"); const session = optionalNonEmptyString(object.session, "session"); if (!handle || !root) { throw new Error("Explicit AMQ routing requires both handle and absolute root"); } if (!isAbsolute(root)) throw new Error("Explicit AMQ root must be absolute"); return { candidate: { source: "flags", handle, root: normalize(root), ...(session ? { session } : {}), ...(requestedMode ? { requestedMode } : {}), }, ...(requestedMode ? { requestedMode } : {}), }; } function parseInheritedEnvironment(environment: Readonly): ParsedEnvironment { const present = INHERITED_KEYS.filter((key) => Object.hasOwn(environment, key)); if (present.length === 0) return {}; const missing = INHERITED_KEYS.filter((key) => !Object.hasOwn(environment, key)); if (missing.length > 0) { throw new Error(`Incomplete inherited AMQ context; missing ${missing.join(", ")}`); } const root = requiredEnvironmentValue(environment, "AM_ROOT"); const rootId = requiredEnvironmentValue(environment, "AM_ROOT_ID"); const baseRoot = requiredEnvironmentValue(environment, "AM_BASE_ROOT"); const baseRootId = requiredEnvironmentValue(environment, "AM_BASE_ROOT_ID"); const handle = requiredEnvironmentValue(environment, "AM_ME"); const session = environment.AM_SESSION; if (typeof session !== "string") throw new Error("AM_SESSION must be present as a string"); if (!isAbsolute(root) || !isAbsolute(baseRoot)) { throw new Error("Inherited AM_ROOT and AM_BASE_ROOT must be absolute"); } const expected: BindingProjectionV1 = { handle, root: normalize(root), rootId, baseRoot: normalize(baseRoot), baseRootId, session, project: "", amqVersion: "", }; return { candidate: { source: "environment", handle, root: expected.root, session, expected, }, }; } function candidateMismatches(candidate: CandidateBinding, live: AmqEnvV1): string[] { const mismatches: string[] = []; compare(mismatches, "handle", candidate.handle, live.me); comparePath(mismatches, "root", candidate.root, live.root); if (candidate.session !== undefined) compare(mismatches, "session", candidate.session, live.session); const expected = candidate.expected; if (!expected) return mismatches; compare(mismatches, "rootId", expected.rootId, live.rootId); comparePath(mismatches, "baseRoot", expected.baseRoot, live.baseRoot); compare(mismatches, "baseRootId", expected.baseRootId, live.baseRootId); if (expected.project !== "") { compare(mismatches, "project", expected.project, live.project); } return mismatches; } function routingConflict(candidate: CandidateBinding, other: BindingProjectionV1 | undefined): boolean { if (!other) return false; if (candidate.handle !== other.handle || normalize(candidate.root) !== normalize(other.root)) return true; return candidate.session !== undefined && candidate.session !== other.session; } function sameBindingAuthority( left: BindingProjectionV1 | undefined, right: BindingProjectionV1, ): boolean { if (!left) return false; return left.handle === right.handle && normalize(left.root) === normalize(right.root) && left.rootId === right.rootId && normalize(left.baseRoot) === normalize(right.baseRoot) && left.baseRootId === right.baseRootId && left.session === right.session; } function withExpected( candidate: CandidateBinding, expected: BindingProjectionV1 | undefined, ): CandidateBinding { return expected ? { ...candidate, expected } : candidate; } function withMode(candidate: CandidateBinding, requestedMode: string | undefined): CandidateBinding { return requestedMode ? { ...candidate, requestedMode } : candidate; } function projectionFromEnv(env: AmqEnvV1): BindingProjectionV1 { return { handle: env.me, root: env.root, rootId: env.rootId, baseRoot: env.baseRoot, baseRootId: env.baseRootId, session: env.session, project: env.project, amqVersion: env.amqVersion, }; } function dormant( reason: DormantReason, diagnostic: string, exitCategory?: AmqExitCategory, ): BindingResolution { return { kind: "dormant", reason, diagnostic, action: "/amq bind", ...(exitCategory ? { exitCategory } : {}), }; } function record(value: unknown, name: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`${name} must be an object`); } return value as Record; } function rejectUnknownKeys( object: Record, allowed: readonly string[], name: string, ): void { const unknown = Object.keys(object).filter((key) => !allowed.includes(key)); if (unknown.length > 0) throw new Error(`${name} contains unknown fields: ${unknown.join(", ")}`); } function requiredString(object: Record, key: string): string { return optionalNonEmptyString(object[key], key) ?? fail(`${key} is required`); } function stringField(object: Record, key: string): string { const value = object[key]; if (typeof value !== "string") throw new Error(`${key} must be a string`); return value; } function numberField(object: Record, key: string): number { const value = object[key]; if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`); return value; } function booleanField(object: Record, key: string): boolean { const value = object[key]; if (typeof value !== "boolean") throw new Error(`${key} must be a boolean`); return value; } function requiredAbsolutePath(object: Record, key: string): string { const path = requiredString(object, key); if (!isAbsolute(path)) throw new Error(`${key} must be absolute`); return normalize(path); } function optionalNonEmptyString(value: unknown, name: string): string | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${name} must be a non-empty string`); } return value; } function requiredEnvironmentValue( environment: Readonly, key: keyof NodeJS.ProcessEnv, ): string { const value = environment[key]; if (typeof value !== "string" || value.length === 0) { throw new Error(`${String(key)} must be a non-empty string`); } return value; } function commandEnvironment(environment: Readonly): NodeJS.ProcessEnv { const projected: NodeJS.ProcessEnv = { ...environment }; for (const key of INHERITED_KEYS) { if (!Object.hasOwn(environment, key)) projected[key] = undefined; } return projected; } function compare(mismatches: string[], field: string, expected: string, actual: string): void { if (expected !== actual) mismatches.push(field); } function comparePath(mismatches: string[], field: string, expected: string, actual: string): void { if (normalize(expected) !== normalize(actual)) mismatches.push(field); } function literalDiagnostic(stderr: string, fallback: string): string { const literal = stderr.trim(); return literal.length > 0 ? literal : fallback; } function fail(message: string): never { throw new Error(message); } function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); }