diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md new file mode 100644 index 0000000000..4141a3ccd9 --- /dev/null +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -0,0 +1,175 @@ +# 0015. Managed Stack Contract Fixtures + +**Status**: proposed +**Date**: 2026-08-10 + +## Problem Statement + +The managed local-stack design combines project, checkout, branch, and named-stack identity with +mutable state, host-wide port allocation, runtime selection, legacy bootstrap, credentials, and +reclamation. These decisions affect both the reusable `@supabase/stack` package and the CLI. If each +layer encodes its own behavior matrix, they will drift and tests will eventually validate +implementation details instead of the behavior developers observe. + +The persistence technology is intentionally not part of the product contract. A later adapter may +use SQLite or another store, but changing storage must not change identity or lifecycle semantics. + +## Decision + +The typed fixtures exported from `@supabase/stack/testing` are the normative executable description +of the M1 managed-stack behavior. Each scenario records: + +- explicit input state; +- a public CLI, Git, direct-stack API, or managed-stack API action; +- the resolved opaque identities and outcome; +- the complete set of permitted managed-state writes and runtime side effects; and +- human, JSON, or programmatic output, including structured warnings and deterministic recovery + guidance. + +Opaque symbolic IDs make the same scenario reusable across an in-memory repository, a persistent +adapter, the managed package, and future CLI integration tests. Linear records the decision history +and links to implementation work; it is not a second source of executable truth. +Each scenario starts from its own isolated `given` state, so a symbolic ID or port has no shared +state across scenarios unless a fixture explicitly references another scenario. Conformance drivers +must reset their repository between scenarios. + +Structured error and warning codes follow ADR 0001's `SCREAMING_SNAKE_CASE` convention. A `report` +is always read-only, and an `error` has no state mutation or runtime effect except the explicit +failed-bootstrap rollback, whose only permitted effects remove partial managed state. + +`@supabase/stack` has two distinct public responsibilities: + +1. Direct `createStack(config)` creates one caller-controlled stack. Omitted stack and runtime roots + are resolved independently as disposable temporary directories and are removed on disposal. + Supplying project, cache, or one state-root path does not make another omitted state root + persistent. Direct usage does not inspect Git, create identity markers, or mutate a global + managed registry. +2. The explicit managed surface owns system-aware discovery, identity, stack selection, ports, + runtime persistence, bootstrap, and reclamation. It accepts an isolated state root or injected + repository so applications and tests can use it without the CLI. + +The CLI is a consumer and presentation layer. It translates arguments into managed operations and +projects managed results into human and JSON output. It must not implement a second identity, +selection, port, runtime, or lifecycle decision path. + +Git workspaces store project and branch-context identities in common local Git configuration, which +is shared by linked worktrees. Checkout identity is stored separately under each checkout's Git +directory. Context writes declare their owning branch so storage adapters cannot persist an unbound +context. A tracked working-tree identity marker is inert: discovery never trusts or rewrites it. +Ordinary non-Git folders persist a project-local, untracked identity marker on first start and +recover that same project, checkout, and context identity on later starts. + +Read-only status remains a successful `report` when it can identify a running stack but finds +unapplied port, credential, or runtime configuration. The report includes a structured warning and +recovery guidance. Conditions that prevent safe identity selection, such as ambiguous ownership, +remain errors. + +Persistence sits behind the managed package's repository boundary. Contract fixtures must run +against a storage-independent test repository and then against each selected persistent adapter. +The choice of SQLite, files, or another implementation does not move product policy into the CLI or +change the package boundary. + +## Testing Strategy + +Tests should be as close as possible to how a developer uses the product: + +- Package integration tests invoke public direct or managed APIs and compare their observable + result with the shared fixture. +- CLI integration tests invoke command handlers and assert argument translation plus human/JSON + projection from that same managed result. +- Repository conformance tests execute the same fixtures against the isolated repository and the + selected persistent adapter. +- Unit tests are reserved for genuinely pure algorithms and public export/type checks; they do not + duplicate the behavior matrix through private helpers. +- E2E tests cover a small number of real subprocess/runtime golden paths. Add a targeted E2E test + when a boundary cannot be represented faithfully in an in-process integration test, rather than + mocking away the behavior under test. + +CLI-2102 checks in the fixture data and public direct-stack boundary before the managed engine and +persistent adapter exist. The implementation issues it unblocks must attach real drivers to these +fixtures. CLI integration coverage begins when a real command boundary exists; a fixture-presence +test is not evidence that an unimplemented command already satisfies the behavior. + +The fixture validator is deliberately fixture lint, not a second implementation of the managed +stack policy. It checks a small set of generic rule families: + +- catalog shape and unique scenario identity; +- referential integrity for selected, written, and effected identities; +- state-write and runtime-effect pairing; +- structured diagnostic and read-only outcome shape; and +- consistency between the managed result and its human, JSON, and API projections. + +The lint implementation lives separately in `managed-stack-contract-validation.ts` so the contract +module remains centered on types and normative scenario data. + +The native qualification matrix derives service names and versions from the package service catalog +so it cannot drift from the shipped manifest. Identity resolution, lifecycle preconditions, port and +runtime selection, bootstrap policy, credential policy, and reclamation semantics belong to the real +managed resolver and engine delivered by the implementation issues below. Further requests to +"validate" those semantics should be covered by running these scenarios against that implementation, +not by expanding this lint into a parallel rule engine. A new lint rule is appropriate only when it +protects a generic fixture-format invariant across behavior areas. + +Native-qualification facts describe the complete M5 launch-scope target, not the package's current +Docker-backed implementation. CLI-2121 through CLI-2141 attach the real native service graph to that +target contract. + +## Implementation Handoff + +The downstream implementation issues own the executable drivers, while this ADR and fixture data +own the expected behavior: + +- CLI-2106, CLI-2107, and CLI-2108 attach the repository and identity resolver to the identity + fixtures, including ordinary folders, worktrees, branches, and orphan handling. +- CLI-2106 and CLI-2108 must store checkout identity beneath the checkout-specific Git directory + without implicitly enabling `extensions.worktreeConfig`; branch contexts remain in common local + Git configuration. +- CLI-2109 attaches automatic legacy bootstrap and rollback-safe publication. +- CLI-2110 attaches exact and automatic port intent, allocation, stickiness, drift, and collisions. +- CLI-2124 attaches runtime selection, persistence, and strict conflict handling. +- CLI-2114 attaches the experimental CLI handlers and verifies that their human and JSON output is + projected from managed results. +- The selected persistent adapter must run the same repository contract as the isolated test + repository before its implementation issue is complete. + +## Rationale + +A single typed matrix makes disagreements visible in review and allows every layer to consume the +same expected result. Public-interface integration tests survive refactors because they assert +commands, API calls, outputs, and state transitions rather than internal call graphs. Injected +repositories keep system-aware behavior programmatically reusable while preventing a persistence +choice from leaking into product semantics. + +Keeping direct and managed stack creation separate also preserves a simple embedding API for tests: +`createStack()` remains isolated, while callers that want branch/worktree-aware state opt into the +managed surface explicitly. + +## Consequences + +### Positive + +- Package, CLI, and persistence adapters share one reviewed behavioral authority. +- Tests describe developer-visible journeys and remain useful through implementation refactors. +- Programmatic consumers can use managed state without importing CLI code. +- Direct test stacks stay isolated from Git and system-wide state. +- Storage technology can change without changing package ownership or managed semantics. + +### Negative / Trade-offs + +- The fixture catalog is intentionally large because it records edge cases that otherwise become + implicit behavior. +- New managed behavior requires updating the shared matrix before layer-specific tests. +- Until downstream implementations attach real drivers, fixture catalog tests validate contract + completeness and projection seams, not the future engine itself. + +## Alternatives Considered + +1. **Duplicate package and CLI test tables**: rejected because identity and lifecycle rules would + drift and reviewers could not identify the authoritative result. +2. **Make CLI tests authoritative**: rejected because managed behavior must be reusable from Node + and Bun without the CLI. +3. **Define behavior through a SQLite schema**: rejected because schemas describe persistence, not + product semantics, and would make a technology choice distort package boundaries. +4. **Put the whole matrix in E2E tests**: rejected because the suite would be slow and failure + diagnosis poor. E2E remains the fallback for boundaries that integration tests cannot exercise + faithfully. diff --git a/docs/adr/README.md b/docs/adr/README.md index 90f4694b45..057d8c3283 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -41,20 +41,21 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi ## ADR index -| ID | Title | Status | -| ---- | ---------------------------------------------------------------------------------------- | -------- | -| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | -| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | -| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | -| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | -| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | +| ID | Title | Status | +| ---- | ------------------------------------------------------------------------------------------ | -------- | +| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | +| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | +| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | +| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | +| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | | 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed | -| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | -| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | -| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | -| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | -| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | -| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | +| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | +| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | +| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | +| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | +| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | +| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | +| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | proposed | ## Template diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index a8d7c72a3e..f6ffb62778 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { candidateCleanupTargets } from "./cleanup.ts"; +import { basename, dirname, join } from "node:path"; +import { candidateCleanupTargets, cleanupAutoManagedPaths } from "./cleanup.ts"; import { dockerContainerName } from "./CleanupTargets.ts"; import { runForegroundOperation, type StackHandle } from "./createStack.ts"; import { StackReadinessError } from "./errors.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; -import { DEFAULT_MANAGED_STACK_NAME, projectKeyForProjectDir } from "./paths.ts"; +import { + DEFAULT_MANAGED_STACK_NAME, + projectKeyForProjectDir, + shortTempPrefixRoot, +} from "./paths.ts"; import { stackMetadata } from "./StackMetadata.ts"; import type { AuthConfig, @@ -354,6 +358,27 @@ describe("resolveConfig startup mode", () => { }); }); +describe("resolveConfig state roots", () => { + it("uses disposable temporary roots when direct callers omit them", async () => { + const config = await resolveConfig({ startupMode: "lazy" }); + + try { + expect(config.autoManagedPaths).toEqual([config.stackRoot, config.runtimeRoot]); + expect(dirname(config.stackRoot)).toBe(shortTempPrefixRoot()); + expect(dirname(config.runtimeRoot)).toBe(shortTempPrefixRoot()); + expect(basename(config.stackRoot)).toMatch(/^sb-stack-/); + expect(basename(config.runtimeRoot)).toMatch(/^sb-run-/); + expect(existsSync(config.stackRoot)).toBe(true); + expect(existsSync(config.runtimeRoot)).toBe(true); + } finally { + cleanupAutoManagedPaths(config); + } + + expect(existsSync(config.stackRoot)).toBe(false); + expect(existsSync(config.runtimeRoot)).toBe(false); + }); +}); + describe("resolveConfig readiness policy", () => { it("uses a finite package default", async () => { const config = await resolveConfig(); diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 06b9b548ed..30fa2fdd75 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -71,6 +71,14 @@ describe("@supabase/stack entrypoints", () => { }); it("isolates consumer test seams in the testing entry", () => { - expect(Object.keys(testing).sort()).toEqual(["DaemonServer", "UnixHttpClient"]); + expect(Object.keys(testing).sort()).toEqual([ + "DaemonServer", + "UnixHttpClient", + "managedNativePlatformByNodeTarget", + "managedNativePlatformFromNode", + "managedNativeServiceMatrix", + "managedStackContractFixtures", + "validateManagedStackContractFixtures", + ]); }); }); diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts new file mode 100644 index 0000000000..997359e5d9 --- /dev/null +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -0,0 +1,490 @@ +import { isDeepStrictEqual } from "node:util"; +import type { + ManagedStackContractFact, + ManagedStackContractJson, + ManagedStackContractScenario, +} from "./managed-stack-contract.ts"; + +const factPrimaryId = (fact: ManagedStackContractFact): string | undefined => { + switch (fact.kind) { + case "checkout": + return fact.checkoutId; + case "credential-state": + return fact.valuesId; + case "direct-stack-state": + return fact.handle; + case "identity-claim": + return `${fact.scope}:${fact.id}`; + case "identity-marker": + return fact.markerId; + case "managed-record": + case "managed-target": + case "operation-result": + case "persisted-runtime": + case "stack": + return fact.stackId; + case "port-assignment": + return `${fact.stackId}:${fact.key}`; + default: + return undefined; + } +}; + +const containsNonFiniteNumber = (value: ManagedStackContractJson | undefined): boolean => { + if (value === undefined) { + return false; + } + if (typeof value === "number") { + return !Number.isFinite(value); + } + if (Array.isArray(value)) { + return value.some(containsNonFiniteNumber); + } + if (value !== null && typeof value === "object") { + return Object.values(value).some(containsNonFiniteNumber); + } + return false; +}; + +const snakeToCamel = (key: string): string => + key.replace(/_([a-z0-9])/g, (_match, character: string) => character.toUpperCase()); + +export const validateManagedStackContractFixtures = ( + fixtures: ReadonlyArray, +): ReadonlyArray => { + const errors: Array = []; + const knownScenarioIds = new Set(fixtures.map((scenario) => scenario.id)); + const scenarioIds = new Set(); + + for (const scenario of fixtures) { + if (scenarioIds.has(scenario.id)) { + errors.push(`${scenario.id}: duplicate scenario ID`); + } + scenarioIds.add(scenario.id); + + if (!scenario.id.startsWith(`${scenario.area}.`)) { + errors.push(`${scenario.id}: ID must start with ${scenario.area}.`); + } + if (scenario.title.trim().length === 0) { + errors.push(`${scenario.id}: title is required`); + } + if (scenario.given.length === 0) { + errors.push(`${scenario.id}: at least one given fact is required`); + } + + if (scenario.when.interface === "cli" || scenario.when.interface === "git") { + if (scenario.when.argv.length === 0 || scenario.when.argv[0]?.trim().length === 0) { + errors.push(`${scenario.id}: argv must start with a public command`); + } + if (scenario.when.cwd.trim().length === 0) { + errors.push(`${scenario.id}: cwd is required for command scenarios`); + } + const givenPaths = scenario.given.flatMap((fact) => + fact.kind === "workspace" || fact.kind === "checkout" ? [fact.path] : [], + ); + if (givenPaths.length > 0 && !givenPaths.includes(scenario.when.cwd)) { + errors.push( + `${scenario.id}: cwd ${scenario.when.cwd} does not match a given workspace or checkout path`, + ); + } + } else { + if (scenario.when.method.trim().length === 0) { + errors.push(`${scenario.id}: public API method is required`); + } + const referencedScenarioId = scenario.when.input.scenarioId; + if ( + referencedScenarioId !== undefined && + (typeof referencedScenarioId !== "string" || !knownScenarioIds.has(referencedScenarioId)) + ) { + errors.push(`${scenario.id}: references unknown scenario ID ${referencedScenarioId}`); + } + if (containsNonFiniteNumber(scenario.when.input)) { + errors.push(`${scenario.id}: public API input contains a non-finite number`); + } + } + + const { output } = scenario.expected; + if (scenario.when.interface === "cli") { + const cliArgv = scenario.when.argv; + const jsonRequested = cliArgv.some( + (argument, index) => + argument === "--output=json" || + ((argument === "--output" || argument === "-o") && cliArgv[index + 1] === "json"), + ); + if (jsonRequested && output.json === undefined) { + errors.push(`${scenario.id}: JSON CLI invocation requires a JSON projection`); + } else if (!jsonRequested && output.human === undefined) { + errors.push(`${scenario.id}: default CLI invocation requires a human projection`); + } + } + const returnsVoid = + scenario.when.interface === "stack-api" && scenario.when.method === "dispose"; + if ( + !returnsVoid && + output.human === undefined && + output.json === undefined && + output.api === undefined + ) { + errors.push(`${scenario.id}: at least one observable output is required`); + } + if (output.human !== undefined && output.human.summary.trim().length === 0) { + errors.push(`${scenario.id}: human summary is required`); + } + for (const key of Object.keys(output.json ?? {})) { + if (!/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(key)) { + errors.push(`${scenario.id}: JSON projection key ${key} must use snake_case`); + } + } + for (const key of Object.keys(output.api ?? {})) { + if (key.includes("_")) { + errors.push(`${scenario.id}: API projection key ${key} must not use snake_case`); + } + } + const jsonValues: ReadonlyArray< + readonly [label: string, value: ManagedStackContractJson | undefined] + > = [ + ["managed detail data", scenario.expected.details], + ["JSON projection", output.json], + ["API projection", output.api], + ]; + for (const [label, value] of jsonValues) { + if (containsNonFiniteNumber(value)) { + errors.push(`${scenario.id}: ${label} contains a non-finite number`); + } + } + + if (scenario.expected.outcome === "error") { + if (scenario.expected.error === undefined) { + errors.push(`${scenario.id}: error outcome requires structured error metadata`); + } else if (scenario.expected.error.recovery.length === 0) { + errors.push(`${scenario.id}: error outcome requires recovery guidance`); + } + } else if (scenario.expected.error !== undefined) { + errors.push(`${scenario.id}: non-error outcome cannot include error metadata`); + } + + if (scenario.expected.warning !== undefined) { + if (scenario.expected.outcome === "error") { + errors.push(`${scenario.id}: error outcome cannot also include warning metadata`); + } + if (scenario.expected.warning.recovery.length === 0) { + errors.push(`${scenario.id}: warning metadata requires recovery guidance`); + } + } + + for (const diagnostic of [scenario.expected.error, scenario.expected.warning]) { + if (diagnostic === undefined) { + continue; + } + if (!/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(diagnostic.code)) { + errors.push( + `${scenario.id}: diagnostic code ${diagnostic.code} must use SCREAMING_SNAKE_CASE`, + ); + } + if (diagnostic.message.trim().length === 0) { + errors.push(`${scenario.id}: diagnostic message is required`); + } + if (diagnostic.recovery.some((step) => step.trim().length === 0)) { + errors.push(`${scenario.id}: diagnostic recovery steps must not be blank`); + } + } + + const hasMutation = + scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0; + if ( + (scenario.expected.outcome === "report" || scenario.expected.outcome === "no-op") && + hasMutation + ) { + errors.push(`${scenario.id}: ${scenario.expected.outcome} outcome must not mutate state`); + } + if (scenario.expected.outcome === "error" && hasMutation) { + const isBootstrapRollback = scenario.expected.error?.code === "LEGACY_BOOTSTRAP_FAILED"; + const containsOnlyRollbackCleanup = + scenario.expected.writes.every( + (write) => write.target === "managed-state" && write.operation === "delete", + ) && scenario.expected.runtimeEffects.every((effect) => effect.operation === "delete"); + if (!isBootstrapRollback || !containsOnlyRollbackCleanup) { + errors.push(`${scenario.id}: error outcome must not mutate state outside rollback cleanup`); + } + } + + const declaredIds = new Set(); + const factsByPrimaryId = new Map(); + const declareId = (id: string): void => { + if (id.trim().length === 0) { + errors.push(`${scenario.id}: declared ID is required`); + } else { + declaredIds.add(id); + } + }; + for (const fact of scenario.given) { + const primaryId = factPrimaryId(fact); + if (primaryId !== undefined) { + const factKey = `${fact.kind}:${primaryId}`; + const previousFact = factsByPrimaryId.get(factKey); + if (previousFact !== undefined && !isDeepStrictEqual(previousFact, fact)) { + errors.push(`${scenario.id}: conflicting ${fact.kind} facts for ID ${primaryId}`); + } else if (previousFact === undefined) { + factsByPrimaryId.set(factKey, fact); + } + } + + switch (fact.kind) { + case "branch": + declareId(fact.contextId); + break; + case "checkout": + declareId(fact.projectId); + declareId(fact.checkoutId); + break; + case "credential-state": + declareId(fact.valuesId); + if (fact.previousValuesId !== undefined) { + declareId(fact.previousValuesId); + } + break; + case "direct-stack-state": + declareId(fact.handle); + for (const root of fact.temporaryRoots) { + declareId(root.stateId); + } + break; + case "identity-claim": + declareId(fact.id); + break; + case "identity-marker": + declareId(fact.markerId); + declareId(fact.projectId); + declareId(fact.checkoutId); + declareId(fact.contextId); + break; + case "managed-record": + case "managed-target": + case "operation-result": + case "persisted-runtime": + declareId(fact.stackId); + break; + case "occupied-port": + if (fact.ownerId !== undefined) { + declareId(fact.ownerId); + } + break; + case "port-assignment": + declareId(fact.stackId); + break; + case "stack": + declareId(fact.checkoutId); + declareId(fact.contextId); + declareId(fact.stackId); + break; + default: + break; + } + } + + for (const write of scenario.expected.writes) { + if (write.id.trim().length === 0) { + errors.push(`${scenario.id}: write ID is required`); + continue; + } + if ( + write.operation === "copy" || + write.operation === "create" || + write.operation === "publish" + ) { + declareId(write.id); + } + + if (write.target === "identity-marker") { + declareId(write.projectId); + declareId(write.checkoutId); + declareId(write.contextId); + } + } + for (const write of scenario.expected.writes) { + if ( + write.operation !== "copy" && + write.operation !== "create" && + write.operation !== "publish" && + !declaredIds.has(write.id) + ) { + errors.push( + `${scenario.id}: ${write.target} ${write.operation} references undeclared ID ${write.id}`, + ); + } + } + + const selection = scenario.expected.selection; + if (selection !== undefined) { + for (const id of [ + selection.projectId, + selection.checkoutId, + selection.contextId, + selection.stackId, + ]) { + if (!declaredIds.has(id)) { + errors.push(`${scenario.id}: selection references undeclared ID ${id}`); + } + } + const selectedStackFact = factsByPrimaryId.get(`stack:${selection.stackId}`); + if (selectedStackFact?.kind === "stack" && selectedStackFact.name !== selection.stackName) { + errors.push( + `${scenario.id}: selected stack name ${selection.stackName} disagrees with stack ${selection.stackId}`, + ); + } + if ( + selectedStackFact?.kind === "stack" && + selectedStackFact.contextId !== selection.contextId + ) { + errors.push( + `${scenario.id}: selected context ${selection.contextId} disagrees with stack ${selection.stackId}`, + ); + } + if ( + selectedStackFact?.kind === "stack" && + selectedStackFact.checkoutId !== selection.checkoutId + ) { + errors.push( + `${scenario.id}: selected checkout ${selection.checkoutId} disagrees with stack ${selection.stackId}`, + ); + } + } + + for (const effect of scenario.expected.runtimeEffects) { + if (effect.stackId.trim().length === 0) { + errors.push(`${scenario.id}: runtime effect stack ID is required`); + continue; + } + if (!declaredIds.has(effect.stackId)) { + errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); + } + + const hasWrite = scenario.expected.writes.some((write) => { + if (write.id !== effect.stackId) { + return false; + } + switch (effect.operation) { + case "copy": + return write.target === "managed-state" && write.operation === "copy"; + case "delete": + return write.target === "managed-state" && write.operation === "delete"; + case "start": + return write.target === "runtime-state" && write.operation === "start"; + case "stop": + return ( + write.target === "runtime-state" && + (write.operation === "delete" || write.operation === "update") + ); + } + }); + if (!hasWrite) { + errors.push( + `${scenario.id}: ${effect.operation} runtime effect requires a matching state write`, + ); + } + } + + for (const write of scenario.expected.writes) { + const requiredRuntimeOperation = + write.target === "runtime-state" && write.operation === "start" + ? "start" + : write.target === "runtime-state" && + (write.operation === "delete" || write.operation === "update") + ? "stop" + : write.target === "managed-state" && write.operation === "copy" + ? "copy" + : write.target === "managed-state" && write.operation === "delete" + ? "delete" + : undefined; + if ( + requiredRuntimeOperation !== undefined && + !scenario.expected.runtimeEffects.some( + (effect) => effect.operation === requiredRuntimeOperation && effect.stackId === write.id, + ) + ) { + errors.push( + `${scenario.id}: ${write.target} ${write.operation} requires a matching runtime effect`, + ); + } + } + + const checkProjection = ( + projection: Readonly> | undefined, + key: string, + expected: ManagedStackContractJson, + ): void => { + if (projection?.[key] !== undefined && !isDeepStrictEqual(projection[key], expected)) { + errors.push(`${scenario.id}: projected ${key} disagrees with the managed result`); + } + }; + + if (output.json !== undefined && output.json.outcome === undefined) { + errors.push(`${scenario.id}: JSON projection requires an outcome`); + } + const diagnosticCode = scenario.expected.error?.code ?? scenario.expected.warning?.code; + if ( + output.json !== undefined && + diagnosticCode !== undefined && + output.json.code === undefined + ) { + errors.push(`${scenario.id}: JSON projection requires a code`); + } + for (const projection of [output.json, output.api]) { + checkProjection(projection, "outcome", scenario.expected.outcome); + if (diagnosticCode !== undefined) { + checkProjection(projection, "code", diagnosticCode); + } + } + for (const [key, value] of Object.entries(scenario.expected.details ?? {})) { + checkProjection(output.json, key, value); + checkProjection(output.api, key, value); + const apiKey = snakeToCamel(key); + if (apiKey !== key) { + checkProjection(output.api, apiKey, value); + } + } + + if (selection !== undefined) { + checkProjection(output.json, "project_id", selection.projectId); + checkProjection(output.json, "checkout_id", selection.checkoutId); + checkProjection(output.json, "context_id", selection.contextId); + checkProjection(output.json, "stack_id", selection.stackId); + checkProjection(output.json, "stack_name", selection.stackName); + checkProjection(output.api, "projectId", selection.projectId); + checkProjection(output.api, "checkoutId", selection.checkoutId); + checkProjection(output.api, "contextId", selection.contextId); + checkProjection(output.api, "stackId", selection.stackId); + checkProjection(output.api, "stackName", selection.stackName); + checkProjection(output.human?.fields, "projectId", selection.projectId); + checkProjection(output.human?.fields, "checkoutId", selection.checkoutId); + checkProjection(output.human?.fields, "contextId", selection.contextId); + checkProjection(output.human?.fields, "stackId", selection.stackId); + checkProjection(output.human?.fields, "stack", selection.stackName); + checkProjection(output.human?.fields, "stackName", selection.stackName); + } + + const expectedRecovery = + scenario.expected.error?.recovery ?? scenario.expected.warning?.recovery; + if ( + output.human !== undefined && + expectedRecovery !== undefined && + (output.human.recovery === undefined || + output.human.recovery.length !== expectedRecovery.length || + output.human.recovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: human recovery disagrees with the managed result`); + } + const jsonRecovery = output.json?.recovery; + if ( + output.json !== undefined && + expectedRecovery !== undefined && + (!Array.isArray(jsonRecovery) || + jsonRecovery.length !== expectedRecovery.length || + jsonRecovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: JSON recovery disagrees with the managed result`); + } + } + + return errors; +}; diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts new file mode 100644 index 0000000000..2fbbec2924 --- /dev/null +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -0,0 +1,1346 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createStack } from "./node.ts"; +import { + managedNativePlatformByNodeTarget, + managedNativePlatformFromNode, + managedNativeServiceMatrix, + managedStackContractFixtures, + type ManagedStackContractScenario, + validateManagedStackContractFixtures, +} from "./testing.ts"; +import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./versions.ts"; + +const { createdTempRoots } = vi.hoisted(() => ({ createdTempRoots: new Array() })); + +vi.mock("node:fs", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + mkdtempSync(prefix: string) { + const root = fs.mkdtempSync(prefix); + createdTempRoots.push(root); + return root; + }, + }; +}); + +const projectDirectStackHandle = (stack: { readonly url: string; readonly dbUrl: string }) => ({ + url: stack.url.replace(/:\d+$/, ":"), + dbUrl: stack.dbUrl.replace(/:\d+\//, ":/"), +}); + +const snapshotDirectoryTree = (root: string): ReadonlyArray => { + const paths: Array = []; + const visit = (directory: string, relativeDirectory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relativePath = join(relativeDirectory, entry.name); + paths.push(entry.isDirectory() ? `${relativePath}/` : relativePath); + if (entry.isDirectory()) { + visit(join(directory, entry.name), relativePath); + } + } + }; + + visit(root, ""); + return paths.sort(); +}; + +describe("managed stack acceptance contract", () => { + it("keeps every shared scenario readable and executable through a public interface", () => { + expect(validateManagedStackContractFixtures(managedStackContractFixtures)).toEqual([]); + }); + + it("lints structural, referential, effect, and projection mistakes", () => { + const findScenario = (id: string): ManagedStackContractScenario => { + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + (candidate) => candidate.id === id, + ); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + + const reuse = findScenario("identity.return-to-branch-reuses-stack"); + const portConflict = findScenario("ports.explicit-port-conflict-fails"); + const readOnly = findScenario("identity.branch-copy-read-only-does-not-write"); + const noOp = findScenario("reclamation.delete-repeat-is-idempotent"); + const freshBootstrap = findScenario("bootstrap.absent-legacy-starts-fresh"); + const repositoryContract = findScenario("api-boundary.repository-contract-is-storage-agnostic"); + const persistedRuntime = findScenario("runtime.persisted-runtime-reused-for-auto"); + const defaultOutputCli = findScenario("identity.non-git-folder-first-start-persists-identity"); + const jsonOutputCli = findScenario("identity.read-only-unregistered-checkout-does-not-write"); + const repositoryAction = repositoryContract.when; + const reusedStack = reuse.given.find((fact) => fact.kind === "stack"); + if ( + reuse.expected.selection === undefined || + reuse.when.interface !== "cli" || + reuse.expected.output.json === undefined || + reuse.expected.output.human === undefined || + reusedStack === undefined || + portConflict.expected.error === undefined || + portConflict.expected.output.json === undefined || + freshBootstrap.expected.details === undefined || + freshBootstrap.expected.output.json === undefined || + repositoryAction.interface !== "managed-api" + ) { + throw new Error("lint examples require selected and structured fixture outputs"); + } + + const cases: ReadonlyArray<{ + readonly fixtures: ReadonlyArray; + readonly expectedError: string | ReadonlyArray; + }> = [ + { + fixtures: [ + { + ...reuse, + expected: { ...reuse.expected, writes: [] }, + }, + ], + expectedError: `${reuse.id}: start runtime effect requires a matching state write`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, stackId: "stack-undeclared" }, + }, + }, + ], + expectedError: `${reuse.id}: selection references undeclared ID stack-undeclared`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, outcome: "create" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: projected outcome disagrees with the managed result`, + }, + { + fixtures: [ + { + ...portConflict, + expected: { + ...portConflict.expected, + error: { ...portConflict.expected.error, code: "exact_port_occupied" }, + output: { + ...portConflict.expected.output, + json: { ...portConflict.expected.output.json, code: "exact_port_occupied" }, + }, + }, + }, + ], + expectedError: `${portConflict.id}: diagnostic code exact_port_occupied must use SCREAMING_SNAKE_CASE`, + }, + { + fixtures: [ + { + ...readOnly, + expected: { + ...readOnly.expected, + writes: [{ target: "registry", operation: "update", id: "context-main" }], + }, + }, + ], + expectedError: `${readOnly.id}: report outcome must not mutate state`, + }, + { + fixtures: [reuse, reuse], + expectedError: `${reuse.id}: duplicate scenario ID`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + writes: [ + ...reuse.expected.writes, + { target: "registry", operation: "publish", id: "" }, + ], + }, + }, + ], + expectedError: `${reuse.id}: write ID is required`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + runtimeEffects: [ + ...reuse.expected.runtimeEffects, + { operation: "start", stackId: "" }, + ], + }, + }, + ], + expectedError: `${reuse.id}: runtime effect stack ID is required`, + }, + { + fixtures: [ + { + ...reuse, + given: reuse.given.map((fact) => + fact.kind === "checkout" ? { ...fact, projectId: "" } : fact, + ), + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, projectId: "" }, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, project_id: "" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: declared ID is required`, + }, + { + fixtures: [ + { + ...freshBootstrap, + expected: { + ...freshBootstrap.expected, + output: { + ...freshBootstrap.expected.output, + json: { + ...freshBootstrap.expected.output.json, + legacy_state_mutated: { value: true }, + }, + }, + }, + }, + ], + expectedError: `${freshBootstrap.id}: projected legacy_state_mutated disagrees with the managed result`, + }, + { + fixtures: [ + { + ...portConflict, + expected: { + ...portConflict.expected, + error: { + ...portConflict.expected.error, + message: " ", + recovery: [" "], + }, + }, + }, + ], + expectedError: [ + `${portConflict.id}: diagnostic message is required`, + `${portConflict.id}: diagnostic recovery steps must not be blank`, + ], + }, + { + fixtures: [ + { + ...noOp, + expected: { + ...noOp.expected, + writes: [{ target: "registry", operation: "update", id: "stack-orphan" }], + }, + }, + ], + expectedError: `${noOp.id}: no-op outcome must not mutate state`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, stackName: "review" }, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, stack_name: "review" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: selected stack name review disagrees with stack stack-main-default`, + }, + { + fixtures: [ + { + ...reuse, + given: [...reuse.given, { ...reusedStack, lifecycle: "running" }], + }, + ], + expectedError: `${reuse.id}: conflicting stack facts for ID stack-main-default`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + human: { ...reuse.expected.output.human, summary: " " }, + }, + }, + }, + ], + expectedError: `${reuse.id}: human summary is required`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, stackName: "default" }, + api: { stack_id: "stack-main-default" }, + }, + }, + }, + ], + expectedError: [ + `${reuse.id}: JSON projection key stackName must use snake_case`, + `${reuse.id}: API projection key stack_id must not use snake_case`, + ], + }, + { + fixtures: [{ ...reuse, when: { ...reuse.when, cwd: "another-checkout" } }], + expectedError: `${reuse.id}: cwd another-checkout does not match a given workspace or checkout path`, + }, + { + fixtures: [ + { + ...reuse, + given: [ + ...reuse.given, + { + kind: "checkout", + path: "checkout-b", + projectId: "project-a", + checkoutId: "checkout-b", + }, + ], + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, checkoutId: "checkout-b" }, + }, + }, + ], + expectedError: `${reuse.id}: selected checkout checkout-b disagrees with stack stack-main-default`, + }, + { + fixtures: managedStackContractFixtures.map((scenario) => + scenario.id === repositoryContract.id + ? { + ...repositoryContract, + when: { + ...repositoryAction, + input: { + ...repositoryAction.input, + scenarioId: "identity.missing-contract-scenario", + }, + }, + } + : scenario, + ), + expectedError: `${repositoryContract.id}: references unknown scenario ID identity.missing-contract-scenario`, + }, + { + fixtures: [{ ...reuse, when: { ...reuse.when, argv: [" "] } }], + expectedError: `${reuse.id}: argv must start with a public command`, + }, + { + fixtures: [ + { + ...persistedRuntime, + given: [ + ...persistedRuntime.given, + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + ], + }, + ], + expectedError: `${persistedRuntime.id}: conflicting persisted-runtime facts for ID stack-main-default`, + }, + { + fixtures: [ + { + ...freshBootstrap, + expected: { + ...freshBootstrap.expected, + details: { ...freshBootstrap.expected.details, invalid_number: Number.NaN }, + }, + }, + ], + expectedError: `${freshBootstrap.id}: managed detail data contains a non-finite number`, + }, + { + fixtures: [ + { + ...defaultOutputCli, + expected: { + ...defaultOutputCli.expected, + output: { ...defaultOutputCli.expected.output, human: undefined }, + }, + }, + ], + expectedError: `${defaultOutputCli.id}: default CLI invocation requires a human projection`, + }, + { + fixtures: [ + { + ...jsonOutputCli, + expected: { + ...jsonOutputCli.expected, + output: { ...jsonOutputCli.expected.output, json: undefined }, + }, + }, + ], + expectedError: `${jsonOutputCli.id}: JSON CLI invocation requires a JSON projection`, + }, + { + fixtures: [ + { + ...reuse, + given: [ + ...reuse.given, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: false }, + ], + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, contextId: "context-feat" }, + output: { + ...reuse.expected.output, + human: { + ...reuse.expected.output.human, + fields: { ...reuse.expected.output.human.fields, contextId: "context-feat" }, + }, + json: { ...reuse.expected.output.json, context_id: "context-feat" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: selected context context-feat disagrees with stack stack-main-default`, + }, + ]; + + for (const testCase of cases) { + const expectedErrors = + typeof testCase.expectedError === "string" + ? [testCase.expectedError] + : testCase.expectedError; + for (const expectedError of expectedErrors) { + expect(validateManagedStackContractFixtures(testCase.fixtures)).toContain(expectedError); + } + } + }); + + it("covers the approved identity journeys through public commands and APIs", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "identity") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "identity.branch-commit-preserves-context", + "identity.branch-copy-ambiguous-read-only", + "identity.branch-copy-known-owner-creates-context-on-mutation", + "identity.branch-copy-read-only-does-not-write", + "identity.branch-create-and-switch-is-no-op", + "identity.branch-delete-recreate-creates-context", + "identity.branch-rebase-preserves-context", + "identity.branch-rename-preserves-context", + "identity.branch-reset-preserves-context", + "identity.concurrent-create-publishes-once", + "identity.copied-checkout-reports-duplicate-claim", + "identity.detached-commits-reuse-checkout-context", + "identity.folder-to-git-ambiguous-claim-fails", + "identity.folder-to-git-exact-claim-preserves-identity", + "identity.folder-to-git-without-claim-creates-git-identity", + "identity.fresh-clone-creates-project-and-checkout", + "identity.fresh-clone-ignores-tracked-marker", + "identity.inaccessible-previous-path-fails", + "identity.invalid-stack-name-double-dot-fails", + "identity.invalid-stack-name-leading-hyphen-fails", + "identity.invalid-stack-name-repeated-dot-fails", + "identity.invalid-stack-name-single-dot-fails", + "identity.invalid-stack-name-too-long-fails", + "identity.invalid-stack-name-trailing-hyphen-fails", + "identity.invalid-stack-name-uppercase-underscore-fails", + "identity.linked-worktrees-share-project-not-checkout", + "identity.manual-ref-replacement-orphans-context", + "identity.missing-previous-path-rebinds-checkout", + "identity.moved-checkout-reuses-identity", + "identity.named-stacks-are-context-scoped", + "identity.new-branch-first-start-creates-stack", + "identity.non-git-folder-first-start-persists-identity", + "identity.non-git-folder-recovers-persisted-identity", + "identity.original-gone-turns-copy-into-rename", + "identity.read-only-unregistered-checkout-does-not-write", + "identity.return-to-branch-reuses-stack", + "identity.same-branch-in-two-worktrees-is-isolated", + "identity.same-checkout-branch-and-name-reuses-stack", + "identity.same-commit-different-branches-are-independent", + "identity.symlink-alias-reuses-checkout", + "identity.valid-stack-names-resolve-deterministically", + "identity.bare-repository-linked-worktrees-share-project", + ].sort(), + ); + }); + + it("shares branch contexts across worktrees while checkout identity keeps stacks isolated", () => { + const findIdentityScenario = (id: string): ManagedStackContractScenario => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + const linkedWorktrees = findIdentityScenario( + "identity.linked-worktrees-share-project-not-checkout", + ); + const forcedBranch = findIdentityScenario("identity.same-branch-in-two-worktrees-is-isolated"); + const bareWorktrees = findIdentityScenario( + "identity.bare-repository-linked-worktrees-share-project", + ); + + expect(linkedWorktrees).toMatchObject({ + expected: { + selection: { checkoutId: "checkout-b", contextId: "context-main" }, + writes: expect.arrayContaining([ + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + ]), + }, + }); + expect(forcedBranch).toMatchObject({ + given: expect.arrayContaining([ + expect.objectContaining({ kind: "branch", name: "main", contextId: "context-main" }), + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ]), + expected: { + selection: { + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + }, + }, + }); + expect(forcedBranch.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); + expect(bareWorktrees).toMatchObject({ + given: expect.arrayContaining([ + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ]), + expected: { + selection: { checkoutId: "checkout-b", contextId: "context-main" }, + }, + }); + expect(bareWorktrees.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); + }); + + it("does not rewrite branch context after Git has preserved a rename", () => { + for (const id of [ + "identity.branch-rename-preserves-context", + "identity.original-gone-turns-copy-into-rename", + ]) { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + expect(scenario?.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); + } + }); + + it("persists and recovers ordinary-folder identity across starts", () => { + const firstStart = managedStackContractFixtures.find( + ({ id }) => id === "identity.non-git-folder-first-start-persists-identity", + ); + const laterStart = managedStackContractFixtures.find( + ({ id }) => id === "identity.non-git-folder-recovers-persisted-identity", + ); + + expect(firstStart).toMatchObject({ + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + }, + writes: expect.arrayContaining([ + { + target: "identity-marker", + operation: "create", + id: "marker-project-a", + storage: "project-local-untracked", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + }, + ]), + }, + }); + expect(laterStart).toMatchObject({ + given: expect.arrayContaining([ + { + kind: "identity-marker", + markerId: "marker-project-a", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + tracked: false, + }, + ]), + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + }, + }, + }); + }); + + it("executes every invalid stack name through the public CLI action", () => { + const invalidNameScenarios = managedStackContractFixtures.filter(({ id }) => + id.startsWith("identity.invalid-stack-name-"), + ); + + expect( + invalidNameScenarios.map((invalidNameScenario) => ({ + action: + invalidNameScenario.when.interface === "cli" ? invalidNameScenario.when.argv : undefined, + names: invalidNameScenario.given.flatMap((fact) => + fact.kind === "stack-names" ? fact.names : [], + ), + })), + ).toEqual([ + { + action: ["start", "--experimental", "--stack", "Feature_A"], + names: ["Feature_A"], + }, + { action: ["start", "--experimental", "--stack", "-review"], names: ["-review"] }, + { + action: ["start", "--experimental", "--stack", "review..two"], + names: ["review..two"], + }, + { + action: ["start", "--experimental", "--stack", "."], + names: ["."], + }, + { + action: ["start", "--experimental", "--stack", ".."], + names: [".."], + }, + { + action: ["start", "--experimental", "--stack", "review-"], + names: ["review-"], + }, + { + action: ["start", "--experimental", "--stack", "a".repeat(64)], + names: ["a".repeat(64)], + }, + ]); + }); + + it("covers exact declarative ports and sticky automatic allocation", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "ports") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "ports.config-change-on-running-stack-reports-drift", + "ports.config-change-on-stopped-stack-applies", + "ports.env-and-remote-values-remain-exact", + "ports.exact-default-value-differs-from-omitted-default", + "ports.explicit-free-port-is-used", + "ports.explicit-port-conflict-fails", + "ports.explicit-port-conflict-with-sibling-fails", + "ports.later-sticky-port-collision-fails", + "ports.new-target-allocates-and-persists-omitted-ports", + "ports.removing-exact-key-keeps-current-port-sticky", + "ports.running-legacy-source-fails-before-allocation", + "ports.sibling-targets-allocate-independent-ports", + "ports.sticky-ports-reuse-on-return", + ].sort(), + ); + }); + + it("freezes runtime selection and the atomic native service graph", () => { + expect(managedNativePlatformByNodeTarget).toEqual({ + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-amd64", + "win32-arm64": "windows-arm64", + "win32-x64": "windows-amd64", + }); + expect(managedNativePlatformFromNode("linux", "x64")).toBe("linux-amd64"); + expect(managedNativePlatformFromNode("linux", "arm64")).toBe("linux-arm64"); + expect(managedNativePlatformFromNode("win32", "x64")).toBe("windows-amd64"); + expect(managedNativePlatformByNodeTarget).toHaveProperty(`${process.platform}-${process.arch}`); + + expect(managedNativeServiceMatrix).toEqual({ + targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], + services: SERVICE_NAMES.map((service) => [service, DEFAULT_VERSIONS[service]]), + }); + + expect( + managedStackContractFixtures + .filter(({ area }) => area === "runtime" || area === "native-qualification") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "native-qualification.all-services-qualify-platform", + "native-qualification.one-service-failure-disables-platform", + "native-qualification.unsupported-platform-fails-preflight", + "runtime.auto-fails-when-neither-runtime-is-available", + "runtime.auto-prefers-docker", + "runtime.auto-selects-fully-qualified-native", + "runtime.config-overrides-default-auto", + "runtime.explicit-and-config-conflict-fails", + "runtime.explicit-api-overrides-auto", + "runtime.explicit-runtime-is-strict", + "runtime.missing-persisted-prerequisite-fails", + "runtime.persisted-runtime-conflict-fails", + "runtime.persisted-runtime-reused-for-auto", + "runtime.status-reports-one-stack-wide-runtime", + ].sort(), + ); + }); + + it("covers read-compatible bootstrap without coupling managed and legacy timelines", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "bootstrap") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "bootstrap.absent-legacy-starts-fresh", + "bootstrap.existing-managed-target-ignores-legacy", + "bootstrap.failed-copy-rolls-back", + "bootstrap.first-start-copies-compatible-legacy-state", + "bootstrap.incompatible-legacy-starts-fresh", + "bootstrap.managed-and-legacy-diverge-after-copy", + "bootstrap.retry-after-failed-copy-succeeds", + "bootstrap.running-legacy-source-fails-without-mutation", + ].sort(), + ); + }); + + it("covers credential authority, stability, drift, and secret boundaries", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "credentials") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "credentials.compatible-legacy-auth-is-retained", + "credentials.configured-values-are-authoritative", + "credentials.explicit-change-applies-after-stop", + "credentials.omitted-values-use-stable-defaults", + "credentials.plaintext-secrets-stay-out-of-global-state", + "credentials.running-change-reports-drift", + "credentials.unchanged-values-survive-restart", + ].sort(), + ); + }); + + it("covers preservation, global deletion, tombstones, prune, and engine-scoped stop", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "reclamation") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "reclamation.branch-delete-does-not-delete-data", + "reclamation.default-stop-preserves-data", + "reclamation.delete-orphan-by-stack-id", + "reclamation.delete-repeat-is-idempotent", + "reclamation.prune-removes-metadata-only", + "reclamation.selectors-stack-and-all-conflict", + "reclamation.selectors-stack-and-stack-id-conflict", + "reclamation.selectors-stack-id-and-all-conflict", + "reclamation.stop-is-engine-scoped", + ].sort(), + ); + }); + + it("rejects every pair of explicit stop selectors through the public CLI action", () => { + expect( + managedStackContractFixtures + .filter(({ id }) => id.startsWith("reclamation.selectors-")) + .map((scenario) => (scenario.when.interface === "cli" ? scenario.when.argv : undefined)), + ).toEqual([ + ["stop", "--experimental", "--stack", "review", "--stack-id", "stack-main-default"], + ["stop", "--experimental", "--stack", "review", "--all"], + ["stop", "--experimental", "--stack-id", "stack-main-default", "--all"], + ]); + }); + + it("freezes the direct, managed, repository, CLI, and portable runtime boundaries", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "api-boundary") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "api-boundary.cli-projects-shared-managed-results", + "api-boundary.direct-create-stack-is-ephemeral", + "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary", + "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary", + "api-boundary.direct-dispose-removes-temporary-roots", + "api-boundary.managed-api-accepts-injected-repository", + "api-boundary.managed-api-accepts-isolated-state-root", + "api-boundary.managed-surface-is-node-and-bun-portable", + "api-boundary.repository-contract-is-storage-agnostic", + ].sort(), + ); + }); + + it("keeps public createStack usage isolated when state roots are omitted", async () => { + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.direct-create-stack-is-ephemeral", + ); + if (scenario?.expected.output.api === undefined) { + throw new Error("direct createStack fixture requires its public API projection"); + } + const testRoot = mkdtempSync(join(tmpdir(), "supabase-direct-contract-")); + const projectDir = join(testRoot, "project"); + const cacheRoot = join(testRoot, "cache"); + const gitConfig = join(projectDir, ".git", "config"); + const identityMarker = join(projectDir, ".supabase", "identity.json"); + const registrySentinel = join(cacheRoot, "managed-registry.json"); + + mkdirSync(join(projectDir, ".git"), { recursive: true }); + mkdirSync(join(projectDir, ".supabase"), { recursive: true }); + mkdirSync(cacheRoot, { recursive: true }); + writeFileSync(gitConfig, "[core]\n\trepositoryformatversion = 0\n"); + writeFileSync(identityMarker, '{"sentinel":true}\n'); + writeFileSync(registrySentinel, '{"sentinel":true}\n'); + const gitTreeBefore = snapshotDirectoryTree(join(projectDir, ".git")); + + try { + const createdRootIndex = createdTempRoots.length; + const stack = await createStack({ cacheRoot, projectDir, startupMode: "lazy" }); + const generatedRoots = createdTempRoots.slice(createdRootIndex); + try { + expect(projectDirectStackHandle(stack)).toEqual(scenario.expected.output.api); + expect(generatedRoots).toHaveLength(2); + expect(generatedRoots.every(existsSync)).toBe(true); + } finally { + expect(await stack.dispose()).toBeUndefined(); + } + + expect(generatedRoots.every((root) => !existsSync(root))).toBe(true); + expect(readFileSync(gitConfig, "utf8")).toBe("[core]\n\trepositoryformatversion = 0\n"); + expect(snapshotDirectoryTree(join(projectDir, ".git"))).toEqual(gitTreeBefore); + expect(readFileSync(identityMarker, "utf8")).toBe('{"sentinel":true}\n'); + expect(readFileSync(registrySentinel, "utf8")).toBe('{"sentinel":true}\n'); + expect(existsSync(join(cacheRoot, "projects"))).toBe(false); + expect(readdirSync(cacheRoot).sort()).toEqual(["managed-registry.json"]); + expect(readdirSync(projectDir).sort()).toEqual([".git", ".supabase"]); + expect(readdirSync(join(projectDir, ".supabase")).sort()).toEqual(["identity.json"]); + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + }); + + it("keeps explicitly supplied state roots while disposing each omitted root independently", async () => { + const explicitRootKinds: ReadonlyArray<"runtime" | "stack"> = ["stack", "runtime"]; + + for (const explicitRootKind of explicitRootKinds) { + const scenarioId = + explicitRootKind === "stack" + ? "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary" + : "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary"; + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + ({ id }) => id === scenarioId, + ); + if (scenario?.expected.output.api === undefined) { + throw new Error(`${scenarioId} fixture requires its public API projection`); + } + const testRoot = mkdtempSync(join(tmpdir(), "supabase-partial-root-contract-")); + const projectDir = join(testRoot, "project"); + const cacheRoot = join(testRoot, "cache"); + const explicitRoot = join(testRoot, `${explicitRootKind}-root`); + const sentinel = join(explicitRoot, "caller-owned"); + + mkdirSync(projectDir, { recursive: true }); + mkdirSync(cacheRoot, { recursive: true }); + mkdirSync(explicitRoot, { recursive: true }); + writeFileSync(sentinel, "caller-owned\n"); + + try { + const createdRootIndex = createdTempRoots.length; + const explicitConfig = + explicitRootKind === "stack" + ? { stackRoot: explicitRoot } + : { runtimeRoot: explicitRoot }; + const stack = await createStack({ + cacheRoot, + projectDir, + startupMode: "lazy", + ...explicitConfig, + }); + const generatedRoots = createdTempRoots.slice(createdRootIndex); + const generatedRoot = generatedRoots[0]; + if (generatedRoot === undefined) { + throw new Error("createStack must generate the omitted state root"); + } + + try { + expect(projectDirectStackHandle(stack)).toEqual(scenario.expected.output.api); + expect(generatedRoots).toHaveLength(1); + expect(existsSync(generatedRoot)).toBe(true); + } finally { + expect(await stack.dispose()).toBeUndefined(); + } + + expect(existsSync(generatedRoot)).toBe(false); + expect(readFileSync(sentinel, "utf8")).toBe("caller-owned\n"); + expect(existsSync(explicitRoot)).toBe(true); + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + } + }); + + it("reuses the existing stack when a developer returns to a branch", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.return-to-branch-reuses-stack", + ); + + expect(scenario).toEqual({ + id: "identity.return-to-branch-reuses-stack", + title: "Returning to a previously used branch reuses its stack", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: true, + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + checkoutId: "checkout-a", + lifecycle: "stopped", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Reused main/default", + fields: { + branch: "main", + stack: "default", + stackId: "stack-main-default", + }, + }, + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + stack_name: "default", + }, + }, + }, + }); + }); + + it("reports an ambiguous copied branch without mutating either branch", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.branch-copy-ambiguous-read-only", + ); + + expect(scenario?.when).toEqual({ + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }); + expect(scenario?.given).toEqual( + expect.arrayContaining([ + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + status: "ambiguous", + }, + ]), + ); + expect(scenario?.expected).toEqual({ + outcome: "error", + error: { + code: "AMBIGUOUS_CONTEXT_OWNER", + message: "Branches feat-copy and main both claim context-main", + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot determine which branch owns this stack context", + fields: { + contextId: "context-main", + branches: "feat-copy, main", + }, + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + json: { + outcome: "error", + code: "AMBIGUOUS_CONTEXT_OWNER", + context_id: "context-main", + branches: ["feat-copy", "main"], + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + }, + }); + }); + + it("fails on an occupied declarative port instead of relocating the stack", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "ports.explicit-port-conflict-fails", + ); + + expect(scenario).toMatchObject({ + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + }, + { + kind: "occupied-port", + port: 54321, + owner: "external-process", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + }, + expected: { + outcome: "error", + error: { + code: "EXACT_PORT_OCCUPIED", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "EXACT_PORT_OCCUPIED", + port: 54321, + config_key: "api.port", + }, + }, + }, + }); + }); + + it("keeps an existing stack on its persisted runtime", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "runtime.persisted-runtime-conflict-fails", + ); + + expect(scenario).toMatchObject({ + area: "runtime", + given: expect.arrayContaining([ + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + { + kind: "runtime-request", + source: "cli", + runtime: "native", + }, + ]), + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + }, + expected: { + outcome: "error", + error: { + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", + persisted_runtime: "docker", + requested_runtime: "native", + }, + }, + }, + }); + }); + + it("bootstraps compatible stopped legacy state without mutating it", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "bootstrap.first-start-copies-compatible-legacy-state", + ); + + expect(scenario).toMatchObject({ + area: "bootstrap", + given: expect.arrayContaining([ + { + kind: "managed-target", + stackId: "stack-main-default", + exists: false, + }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ]), + when: { + interface: "cli", + argv: ["start", "--experimental"], + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + bootstrap: "copied", + legacy_state_mutated: false, + credentials: "preserved", + }, + output: { + json: { + outcome: "create", + bootstrap: "copied", + stack_id: "stack-main-default", + }, + }, + }, + }); + }); + + it("deletes an orphaned stack by opaque ID without a checkout", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "reclamation.delete-orphan-by-stack-id", + ); + + expect(scenario).toMatchObject({ + area: "reclamation", + given: [ + { + kind: "stack", + stackId: "stack-orphan", + checkoutId: "checkout-orphan", + lifecycle: "running", + orphaned: true, + }, + ], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + }, + expected: { + outcome: "delete", + writes: [ + { target: "runtime-state", operation: "delete", id: "stack-orphan" }, + { target: "managed-state", operation: "delete", id: "stack-orphan" }, + { target: "registry", operation: "tombstone", id: "stack-orphan" }, + ], + runtimeEffects: [ + { operation: "stop", stackId: "stack-orphan" }, + { operation: "delete", stackId: "stack-orphan" }, + ], + output: { + json: { + outcome: "delete", + stack_id: "stack-orphan", + tombstoned: true, + }, + }, + }, + }); + }); + + it("keeps direct createStack usage isolated from system-wide managed state", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.direct-create-stack-is-ephemeral", + ); + + expect(scenario).toMatchObject({ + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "omitted", + runtimeRoot: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { startupMode: "lazy" }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["stack", "runtime"], + }, + output: { + api: { + url: "http://127.0.0.1:", + dbUrl: "postgresql://postgres:postgres@127.0.0.1:/postgres", + }, + }, + }, + }); + }); +}); diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts new file mode 100644 index 0000000000..d41429c98b --- /dev/null +++ b/packages/stack/src/managed-stack-contract.ts @@ -0,0 +1,5045 @@ +import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; + +export type ManagedStackContractArea = + | "api-boundary" + | "bootstrap" + | "credentials" + | "identity" + | "native-qualification" + | "ports" + | "reclamation" + | "runtime"; + +export type ManagedStackContractJson = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: ManagedStackContractJson }; + +export type ManagedStackContractFact = + | { + readonly kind: "workspace"; + readonly mode: "bare-worktree" | "git" | "linked-worktree" | "ordinary-folder"; + readonly path: string; + readonly canonicalPath?: string; + readonly previousPath?: string; + readonly previousPathAccess?: "inaccessible" | "missing" | "reachable"; + readonly copiedFrom?: string; + readonly clonedFrom?: string; + } + | { + readonly kind: "workspace-history"; + readonly path: string; + readonly previousMode: "ordinary-folder"; + } + | { + readonly kind: "git-state"; + readonly workspacePath: string; + readonly commonDirectory: string; + readonly gitDirectory: string; + readonly head: "branch" | "detached"; + readonly branch?: string; + readonly commit: string; + readonly trackedIdentityMarker?: boolean; + } + | { + readonly kind: "identity-claim"; + readonly scope: "checkout" | "context" | "project"; + readonly id: string; + readonly path?: string; + readonly owner?: string; + readonly status: "absent" | "ambiguous" | "duplicate" | "exact"; + } + | { + readonly kind: "identity-transition"; + readonly operation: + | "branch-commit" + | "branch-copy" + | "branch-delete-recreate" + | "branch-rebase" + | "branch-rename" + | "branch-reset" + | "checkout-copy" + | "checkout-move" + | "clone" + | "detached-commit" + | "folder-to-git" + | "ref-replacement" + | "symlink-alias"; + readonly from?: string; + readonly to?: string; + readonly originalExists?: boolean; + } + | { + readonly kind: "concurrent-operation"; + readonly operation: "create-stack"; + readonly target: string; + readonly contenders: number; + } + | { + readonly kind: "operation-result"; + readonly operation: "legacy-bootstrap"; + readonly stackId: string; + readonly outcome: "rolled-back"; + } + | { + readonly kind: "stack-names"; + readonly names: ReadonlyArray; + } + | { + readonly kind: "checkout"; + readonly path: string; + readonly projectId: string; + readonly checkoutId: string; + } + | { + readonly kind: "branch"; + readonly name: string; + readonly contextId: string; + readonly checkedOut: boolean; + } + | { + readonly kind: "branch-ref"; + readonly name: string; + readonly commit: string; + } + | { + readonly kind: "branch-history"; + readonly branch: string; + readonly operation: "commit" | "rebase" | "reset"; + readonly fromCommit: string; + readonly toCommit: string; + } + | { + readonly kind: "stack"; + readonly name: string; + readonly stackId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly lifecycle: "running" | "stopped"; + readonly orphaned?: boolean; + } + | { + readonly kind: "config-port"; + readonly key: string; + readonly intent: "automatic" | "exact"; + readonly value?: number; + readonly previousValue?: number; + readonly source?: "environment" | "local" | "omitted" | "remote"; + } + | { + readonly kind: "port-assignment"; + readonly stackId: string; + readonly key: string; + readonly port: number; + readonly intent: "automatic" | "exact"; + } + | { + readonly kind: "occupied-port"; + readonly port: number; + readonly owner: "managed-stack"; + readonly ownerId: string; + } + | { + readonly kind: "occupied-port"; + readonly port: number; + readonly owner: "external-process" | "legacy-stack"; + readonly ownerId?: string; + } + | { + readonly kind: "persisted-runtime"; + readonly stackId: string; + readonly runtime: "docker" | "native"; + } + | { + readonly kind: "runtime-request"; + readonly source: "cli" | "config" | "default" | "managed-api"; + readonly runtime: "auto" | "docker" | "native"; + } + | { + readonly kind: "runtime-availability"; + readonly runtime: "docker" | "native"; + readonly available: boolean; + readonly reason?: string; + } + | { + readonly kind: "native-qualification"; + readonly platform: string; + readonly qualifiedServices: ReadonlyArray; + readonly failedServices: ReadonlyArray; + } + | { + readonly kind: "managed-target"; + readonly stackId: string; + readonly exists: boolean; + } + | { + readonly kind: "managed-record"; + readonly stackId: string; + readonly status: "active" | "orphaned" | "tombstoned"; + } + | { + readonly kind: "legacy-state"; + readonly lifecycle: "absent" | "running" | "stopped"; + readonly database: "absent" | "compatible" | "incompatible"; + readonly storage: "absent" | "compatible" | "incompatible"; + readonly credentials: "absent" | "compatible" | "incompatible"; + } + | { + readonly kind: "credential-state"; + readonly source: "configured" | "legacy" | "local-default" | "persisted"; + readonly valuesId: string; + readonly previousValuesId?: string; + readonly plaintextPresentInGlobalState?: boolean; + } + | { + readonly kind: "identity-marker"; + readonly markerId: string; + readonly workspacePath: string; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly tracked: false; + } + | { + readonly kind: "direct-stack-options"; + readonly stackRoot: "explicit" | "omitted"; + readonly runtimeRoot: "explicit" | "omitted"; + } + | { + readonly kind: "direct-stack-state"; + readonly handle: string; + readonly temporaryRoots: ReadonlyArray<{ + readonly root: "stack" | "runtime"; + readonly stateId: string; + }>; + readonly lifecycle: "created"; + } + | { + readonly kind: "managed-api-options"; + readonly stateRoot: "default" | "isolated"; + readonly stateRootPath?: string; + readonly repository: "in-memory" | "injected" | "persistent-adapter"; + readonly repositoryId?: string; + readonly runtime: "bun" | "node"; + }; + +export interface ManagedStackContractOutput { + readonly human?: { + readonly summary: string; + readonly fields: Readonly>; + readonly recovery?: ReadonlyArray; + }; + readonly json?: Readonly>; + readonly api?: Readonly>; +} + +type ManagedStackContractWrite = + | { + readonly target: "git-config"; + readonly operation: "create" | "update"; + readonly id: string; + readonly scope: "common"; + readonly owner?: string; + } + | { + readonly target: "git-checkout-id"; + readonly operation: "create" | "update"; + readonly id: string; + } + | { + readonly target: "identity-marker"; + readonly operation: "create" | "update"; + readonly id: string; + readonly storage: "project-local-untracked"; + readonly workspacePath: string; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + } + | { + readonly target: "ephemeral-state"; + readonly operation: "create"; + readonly id: string; + } + | { + readonly target: "temporary-root"; + readonly operation: "create" | "delete"; + readonly id: string; + readonly root: "stack" | "runtime"; + } + | { + readonly target: "managed-state"; + readonly operation: "copy" | "create" | "delete" | "update"; + readonly id: string; + } + | { + readonly target: "registry"; + readonly operation: "delete" | "publish" | "tombstone" | "update"; + readonly id: string; + } + | { + readonly target: "runtime-state"; + readonly operation: "delete" | "start" | "update"; + readonly id: string; + }; + +export interface ManagedStackContractEffects { + readonly writes: ReadonlyArray; + readonly runtimeEffects: ReadonlyArray<{ + readonly operation: "copy" | "delete" | "start" | "stop"; + readonly stackId: string; + }>; + readonly output: ManagedStackContractOutput; +} + +export interface ManagedStackContractExpectation extends ManagedStackContractEffects { + readonly outcome: "create" | "delete" | "error" | "no-op" | "report" | "reuse" | "update"; + readonly selection?: { + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly stackId: string; + readonly stackName: string; + }; + readonly error?: { + readonly code: string; + readonly message: string; + readonly recovery: ReadonlyArray; + }; + readonly warning?: { + readonly code: string; + readonly message: string; + readonly recovery: ReadonlyArray; + }; + readonly details?: Readonly>; +} + +export type ManagedStackContractAction = + | { + readonly interface: "cli"; + readonly argv: ReadonlyArray; + readonly cwd: string; + } + | { + readonly interface: "git"; + readonly argv: ReadonlyArray; + readonly cwd: string; + } + | { + readonly interface: "managed-api"; + readonly method: string; + readonly input: Readonly>; + } + | { + readonly interface: "stack-api"; + readonly method: string; + readonly input: Readonly>; + }; + +export interface ManagedStackContractScenario { + readonly id: string; + readonly title: string; + readonly area: ManagedStackContractArea; + readonly given: ReadonlyArray; + readonly when: ManagedStackContractAction; + readonly expected: ManagedStackContractExpectation; +} + +export interface ManagedNativeServiceMatrix { + readonly targetPlatforms: ReadonlyArray; + readonly unsupportedPlatforms: ReadonlyArray; + readonly services: ReadonlyArray; +} + +export const managedNativePlatformByNodeTarget: Readonly> = { + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-amd64", + "win32-arm64": "windows-arm64", + "win32-x64": "windows-amd64", +}; + +export const managedNativePlatformFromNode = ( + os: string, + architecture: string, +): string | undefined => managedNativePlatformByNodeTarget[`${os}-${architecture}`]; + +export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { + targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], + services: SERVICE_NAMES.map((service): readonly [ServiceName, string] => [ + service, + DEFAULT_VERSIONS[service], + ]), +}; + +const directStackApiProjection = { + url: "http://127.0.0.1:", + dbUrl: "postgresql://postgres:postgres@127.0.0.1:/postgres", +}; + +const defineManagedStackContractFixtures = < + const Fixtures extends ReadonlyArray, +>( + fixtures: Fixtures, +): Fixtures => fixtures; + +const branchHistoryFixture = ( + label: "commit" | "rebase" | "reset", + operation: "branch-commit" | "branch-rebase" | "branch-reset", +): ManagedStackContractScenario => ({ + id: `identity.branch-${label}-preserves-context`, + title: `A branch ${label} preserves its context and stack`, + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "branch-history", + branch: "feat-a", + operation: label, + fromCommit: "commit-a", + toCommit: "commit-b", + }, + { kind: "identity-transition", operation, from: "commit-a", to: "commit-b" }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" } }, + }, +}); + +const invalidStackNameFixture = ( + label: + | "double-dot" + | "leading-hyphen" + | "repeated-dot" + | "single-dot" + | "too-long" + | "trailing-hyphen" + | "uppercase-underscore", + stackName: string, +): ManagedStackContractScenario => ({ + id: `identity.invalid-stack-name-${label}-fails`, + title: `The invalid stack name ${stackName} fails before registration`, + area: "identity", + given: [{ kind: "stack-names", names: [stackName] }], + when: { + interface: "cli", + argv: ["start", "--experimental", "--stack", stackName], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "INVALID_STACK_NAME", + message: `${stackName} is not a lowercase DNS-label name`, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: `Invalid stack name: ${stackName}`, + fields: { stack: stackName }, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + json: { + outcome: "error", + code: "INVALID_STACK_NAME", + stack_name: stackName, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + }, + }, +}); + +const mainCheckoutContextFacts = [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, +] satisfies ReadonlyArray; + +const freshManagedStartFacts = (stackId: string): ReadonlyArray => [ + { kind: "managed-target", stackId, exists: false }, + { + kind: "legacy-state", + lifecycle: "absent", + database: "absent", + storage: "absent", + credentials: "absent", + }, +]; + +const freshMainManagedStartFacts = freshManagedStartFacts("stack-main-default"); + +const mainDefaultSelection = { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", +}; + +const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ + { + id: "identity.same-checkout-branch-and-name-reuses-stack", + title: "The same checkout, branch, and stack name resolve the same stack", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "status" }, + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + api: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + }, + }, + }, + { + id: "identity.branch-create-and-switch-is-no-op", + title: "Creating and switching Git branches alone does not touch managed state", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + ], + when: { + interface: "git", + argv: ["switch", "-c", "feat-a"], + cwd: "checkout-a", + }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { managed_command_ran: false }, + output: { + human: { summary: "Switched to a new branch 'feat-a'", fields: {} }, + }, + }, + }, + { + id: "identity.new-branch-first-start-creates-stack", + title: "First start on a new branch creates an independent context and stack", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-feat-a-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-a", contextId: "context-feat-a", checkedOut: true }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-a", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat-a", + stackId: "stack-feat-a-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-feat-a", + scope: "common", + owner: "feat-a", + }, + { target: "registry", operation: "publish", id: "stack-feat-a-default" }, + { target: "managed-state", operation: "create", id: "stack-feat-a-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-a-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-a-default" }], + output: { + human: { + summary: "Created feat-a/default", + fields: { branch: "feat-a", stack: "default", stackId: "stack-feat-a-default" }, + }, + json: { + outcome: "create", + context_id: "context-feat-a", + stack_id: "stack-feat-a-default", + }, + }, + }, + }, + { + id: "identity.branch-rename-preserves-context", + title: "A standard branch rename preserves its context and stack", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "identity-transition", operation: "branch-rename", from: "feature", to: "feat-a" }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { + api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" }, + }, + }, + }, + { + id: "identity.branch-delete-recreate-creates-context", + title: "Deleting and recreating a branch name creates a new context", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-new-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-new", + }, + { + kind: "identity-transition", + operation: "branch-delete-recreate", + from: "feat-a", + to: "feat-a", + }, + { + kind: "identity-claim", + scope: "context", + id: "context-old", + owner: "feat-a", + status: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-new", + stackId: "stack-new-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-new", + scope: "common", + owner: "feat-a", + }, + { target: "registry", operation: "publish", id: "stack-new-default" }, + { target: "managed-state", operation: "create", id: "stack-new-default" }, + { target: "runtime-state", operation: "start", id: "stack-new-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], + details: { orphaned_context_id: "context-old" }, + output: { + human: { + summary: "Created default stack with a new branch context", + fields: { contextId: "context-new", stackId: "stack-new-default", stack: "default" }, + }, + json: { + outcome: "create", + context_id: "context-new", + stack_id: "stack-new-default", + orphaned_context_id: "context-old", + }, + }, + }, + }, + branchHistoryFixture("commit", "branch-commit"), + branchHistoryFixture("rebase", "branch-rebase"), + branchHistoryFixture("reset", "branch-reset"), + { + id: "identity.same-commit-different-branches-are-independent", + title: "Two branches at one commit retain independent contexts", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "branch-ref", name: "main", commit: "shared-commit" }, + { kind: "branch-ref", name: "feat-a", commit: "shared-commit" }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "running", + }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "shared-commit", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "status" }, + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + api: { + outcome: "report", + contextId: "context-feat", + stackId: "stack-feat-default", + otherContextId: "context-main", + }, + }, + }, + }, + { + id: "identity.manual-ref-replacement-orphans-context", + title: "Replacing a branch ref manually creates a new context and orphans the old one", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-new-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "identity-transition", + operation: "ref-replacement", + from: "commit-a", + to: "commit-b", + }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-b", + }, + { + kind: "identity-claim", + scope: "context", + id: "context-old", + owner: "feat-a", + status: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-new", + stackId: "stack-new-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-new", + scope: "common", + owner: "feat-a", + }, + { target: "registry", operation: "publish", id: "stack-new-default" }, + { target: "managed-state", operation: "create", id: "stack-new-default" }, + { target: "runtime-state", operation: "start", id: "stack-new-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], + details: { orphaned_context_id: "context-old", adoption_required: true }, + output: { + human: { + summary: "Created default stack after manual ref replacement", + fields: { contextId: "context-new", stackId: "stack-new-default", stack: "default" }, + }, + json: { + outcome: "create", + context_id: "context-new", + stack_id: "stack-new-default", + orphaned_context_id: "context-old", + }, + }, + }, + }, + { + id: "identity.detached-commits-reuse-checkout-context", + title: "Different detached commits in one checkout reuse its detached context", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "(detached)", contextId: "context-detached", checkedOut: true }, + { + kind: "identity-transition", + operation: "detached-commit", + from: "commit-a", + to: "commit-b", + }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "detached", + commit: "commit-b", + }, + { + kind: "stack", + name: "default", + stackId: "stack-detached-default", + checkoutId: "checkout-a", + contextId: "context-detached", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-detached", + stackId: "stack-detached-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-detached-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-detached-default" }], + output: { + api: { outcome: "reuse", contextId: "context-detached", stackId: "stack-detached-default" }, + }, + }, + }, + { + id: "identity.non-git-folder-first-start-persists-identity", + title: "First start in a non-Git folder persists an untracked local identity", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-workspace-default"), + { + kind: "workspace", + mode: "ordinary-folder", + path: "/work/project-a", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stackName: "default", + }, + writes: [ + { + target: "identity-marker", + operation: "create", + id: "marker-project-a", + storage: "project-local-untracked", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + }, + { target: "registry", operation: "publish", id: "stack-workspace-default" }, + { target: "managed-state", operation: "create", id: "stack-workspace-default" }, + { target: "runtime-state", operation: "start", id: "stack-workspace-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], + details: { identity_marker_tracked: false }, + output: { + human: { + summary: "Created workspace/default", + fields: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stack: "default", + }, + }, + json: { + outcome: "create", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-workspace", + stack_id: "stack-workspace-default", + }, + }, + }, + }, + { + id: "identity.non-git-folder-recovers-persisted-identity", + title: "A later start in a non-Git folder recovers its persisted local identity", + area: "identity", + given: [ + { + kind: "workspace", + mode: "ordinary-folder", + path: "/work/project-a", + }, + { + kind: "identity-marker", + markerId: "marker-project-a", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + tracked: false, + }, + { + kind: "stack", + name: "default", + stackId: "stack-workspace-default", + checkoutId: "checkout-a", + contextId: "context-workspace", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-workspace-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], + output: { + human: { + summary: "Started workspace/default", + fields: { + contextId: "context-workspace", + stackId: "stack-workspace-default", + stack: "default", + }, + }, + json: { + outcome: "reuse", + context_id: "context-workspace", + stack_id: "stack-workspace-default", + }, + }, + }, + }, + { + id: "identity.linked-worktrees-share-project-not-checkout", + title: "Sibling linked worktrees share a project and use independent checkouts", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-b-main-default"), + { kind: "workspace", mode: "linked-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "linked-worktree", path: "worktree-b" }, + { + kind: "git-state", + workspacePath: "worktree-b", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git/worktrees/worktree-b", + head: "branch", + branch: "main", + commit: "commit-b", + }, + { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, + { kind: "identity-claim", scope: "context", id: "context-main", status: "absent" }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + output: { + api: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + }, + }, + }, + }, + { + id: "identity.same-branch-in-two-worktrees-is-isolated", + title: "The same branch forced into two worktrees remains checkout-isolated", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-b-main-default"), + { kind: "workspace", mode: "linked-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "linked-worktree", path: "worktree-b" }, + { + kind: "git-state", + workspacePath: "worktree-b", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git/worktrees/worktree-b", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + output: { + api: { + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + }, + }, + }, + }, + { + id: "identity.named-stacks-are-context-scoped", + title: "Named stacks are scoped inside the active branch context", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-feat-review"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "running", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--stack", "review"], + cwd: "checkout-a", + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-review", + stackName: "review", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-feat-review" }, + { target: "managed-state", operation: "create", id: "stack-feat-review" }, + { target: "runtime-state", operation: "start", id: "stack-feat-review" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-review" }], + output: { + human: { + summary: "Created feat-a/review", + fields: { stack: "review", stackId: "stack-feat-review" }, + }, + json: { outcome: "create", context_id: "context-feat", stack_id: "stack-feat-review" }, + }, + }, + }, + { + id: "identity.moved-checkout-reuses-identity", + title: "Moving a checkout rebinds its existing identity", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + canonicalPath: "/new/project-a", + previousPath: "/old/project-a", + previousPathAccess: "missing", + }, + { + kind: "identity-transition", + operation: "checkout-move", + from: "/old/project-a", + to: "/new/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/old/project-a", + status: "exact", + }, + { + kind: "checkout", + path: "/old/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "/new/project-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "update", id: "checkout-a" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Started main/default after moving the checkout", + fields: { checkoutId: "checkout-a", stackId: "stack-main-default", stack: "default" }, + }, + json: { outcome: "reuse", checkout_id: "checkout-a", rebound_from: "/old/project-a" }, + }, + }, + }, + { + id: "identity.symlink-alias-reuses-checkout", + title: "A symlink alias resolves the canonical checkout identity", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/alias/project-a", + canonicalPath: "/work/project-a", + }, + { + kind: "identity-transition", + operation: "symlink-alias", + from: "/work/project-a", + to: "/alias/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "exact", + }, + { + kind: "checkout", + path: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "/alias/project-a", + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + json: { outcome: "report", checkout_id: "checkout-a", canonical_path: "/work/project-a" }, + }, + }, + }, + { + id: "identity.copied-checkout-reports-duplicate-claim", + title: "A copied checkout reports a duplicate identity while its source exists", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "/copy/project-a", copiedFrom: "/work/project-a" }, + { + kind: "identity-transition", + operation: "checkout-copy", + from: "/work/project-a", + to: "/copy/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "duplicate", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "/copy/project-a", + }, + expected: { + outcome: "error", + error: { + code: "DUPLICATE_CHECKOUT_CLAIM", + message: "Two live paths claim checkout-a", + recovery: [ + "Use the original checkout at /work/project-a", + "Recreate the copy with git clone and run supabase start --experimental", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "DUPLICATE_CHECKOUT_CLAIM", + checkout_id: "checkout-a", + paths: ["/copy/project-a", "/work/project-a"], + recovery: [ + "Use the original checkout at /work/project-a", + "Recreate the copy with git clone and run supabase start --experimental", + ], + }, + }, + }, + }, + { + id: "identity.fresh-clone-creates-project-and-checkout", + title: "A fresh clone receives new project and checkout identities", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-clone-main-default"), + { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, + { + kind: "git-state", + workspacePath: "/clone/project-a", + commonDirectory: "/clone/project-a/.git", + gitDirectory: "/clone/project-a/.git", + head: "branch", + branch: "main", + commit: "clone-commit", + }, + { + kind: "identity-transition", + operation: "clone", + from: "/work/project-a", + to: "/clone/project-a", + }, + { kind: "identity-claim", scope: "project", id: "project-a", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/clone/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-clone", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-clone" }, + { + target: "git-config", + operation: "create", + id: "context-clone-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-clone-main-default" }, + { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], + details: { project_identity_storage: "git-local", git_index_mutated: false }, + output: { + human: { + summary: "Created main/default for the fresh clone", + fields: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stack: "default", + }, + }, + json: { + outcome: "create", + project_id: "project-clone", + checkout_id: "checkout-clone", + context_id: "context-clone-main", + stack_id: "stack-clone-main-default", + }, + }, + }, + }, + { + id: "identity.missing-previous-path-rebinds-checkout", + title: "A missing previous checkout path is rebound automatically", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + previousPath: "/old/project-a", + previousPathAccess: "missing", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/old/project-a", + status: "exact", + }, + { + kind: "checkout", + path: "/old/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "/new/project-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "update", id: "checkout-a" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { api: { outcome: "reuse", checkoutId: "checkout-a", rebound: true } }, + }, + }, + { + id: "identity.inaccessible-previous-path-fails", + title: "An inaccessible previous path fails instead of guessing ownership", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + previousPath: "/mnt/project-a", + previousPathAccess: "inaccessible", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/mnt/project-a", + status: "ambiguous", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/new/project-a" }, + expected: { + outcome: "error", + error: { + code: "CHECKOUT_PATH_INACCESSIBLE", + message: "Cannot verify whether /mnt/project-a still owns checkout-a", + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot safely rebind checkout-a", + fields: { previousPath: "/mnt/project-a", currentPath: "/new/project-a" }, + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, + json: { + outcome: "error", + code: "CHECKOUT_PATH_INACCESSIBLE", + checkout_id: "checkout-a", + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, + }, + }, + }, + { + id: "identity.concurrent-create-publishes-once", + title: "Concurrent creation publishes one stack without aliases", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-feat-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "concurrent-operation", + operation: "create-stack", + target: "context-feat/default", + contenders: 2, + }, + ], + when: { + interface: "managed-api", + method: "startConcurrently", + input: { cwd: "checkout-a", stackName: "default", contenders: 2 }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-feat-default" }, + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { published_stack_count: 1, alias_count: 0, contender_results: ["create", "reuse"] }, + output: { + api: { + stackId: "stack-feat-default", + publishedStackCount: 1, + aliasCount: 0, + contenderResults: ["create", "reuse"], + }, + }, + }, + }, + invalidStackNameFixture("uppercase-underscore", "Feature_A"), + invalidStackNameFixture("leading-hyphen", "-review"), + invalidStackNameFixture("repeated-dot", "review..two"), + invalidStackNameFixture("single-dot", "."), + invalidStackNameFixture("double-dot", ".."), + invalidStackNameFixture("trailing-hyphen", "review-"), + invalidStackNameFixture("too-long", "a".repeat(64)), + { + id: "identity.valid-stack-names-resolve-deterministically", + title: "Default and lowercase DNS-label stack names resolve deterministically", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "stack-names", names: ["default", "review-42"] }, + ], + when: { + interface: "managed-api", + method: "resolveStackNames", + input: { cwd: "checkout-a", stackNames: ["default", "review-42"] }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { + default_stack_id: "stack-feat-default", + review_42_stack_id: "stack-feat-review-42", + }, + output: { + api: { + default: { contextId: "context-feat", stackId: "stack-feat-default" }, + "review-42": { contextId: "context-feat", stackId: "stack-feat-review-42" }, + }, + }, + }, + }, + { + id: "identity.read-only-unregistered-checkout-does-not-write", + title: "Read-only discovery of an unregistered checkout performs no writes", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "checkout-new" }, + { kind: "identity-claim", scope: "checkout", id: "checkout-unregistered", status: "absent" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-new", + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { registered: false, identity_marker_created: false }, + output: { json: { outcome: "report", registered: false, stacks: [] } }, + }, + }, + { + id: "identity.branch-copy-known-owner-creates-context-on-mutation", + title: "A copied branch with a known owner gets a new context on first mutation", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-copy-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-copy", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "exact", + }, + { kind: "identity-claim", scope: "context", id: "context-copy", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-copy", + stackId: "stack-copy-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-copy", + scope: "common", + owner: "feat-copy", + }, + { target: "registry", operation: "publish", id: "stack-copy-default" }, + { target: "managed-state", operation: "create", id: "stack-copy-default" }, + { target: "runtime-state", operation: "start", id: "stack-copy-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-copy-default" }], + details: { original_context_id: "context-main", original_owner: "main" }, + output: { + human: { + summary: "Created feat-copy/default with a new branch context", + fields: { contextId: "context-copy", stackId: "stack-copy-default", stack: "default" }, + }, + json: { + outcome: "create", + branch: "feat-copy", + context_id: "context-copy", + original_context_id: "context-main", + stack_id: "stack-copy-default", + }, + }, + }, + }, + { + id: "identity.branch-copy-read-only-does-not-write", + title: "Read-only discovery reports a copied-branch conflict without resolving it", + area: "identity", + given: [ + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-copy", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "exact", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + warning: { + code: "COPIED_BRANCH_CONTEXT_CONFLICT", + message: "feat-copy copied context-main from main", + recovery: [ + "Run supabase start --experimental to create an independent context for feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "report", + code: "COPIED_BRANCH_CONTEXT_CONFLICT", + branch: "feat-copy", + owner: "main", + context_id: "context-main", + recovery: [ + "Run supabase start --experimental to create an independent context for feat-copy", + ], + }, + }, + }, + }, + { + id: "identity.original-gone-turns-copy-into-rename", + title: "A copied context is preserved as a rename when its original branch is gone", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "renamed", + originalExists: false, + }, + { kind: "branch", name: "renamed", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "absent", + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Started renamed/default with its preserved context", + fields: { contextId: "context-main", stackId: "stack-main-default", stack: "default" }, + }, + json: { + outcome: "reuse", + branch: "renamed", + context_id: "context-main", + rename_detected: true, + }, + }, + }, + }, + { + id: "identity.fresh-clone-ignores-tracked-marker", + title: "A tracked non-Git identity marker is inert in a fresh Git clone", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-clone-main-default"), + { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, + { + kind: "git-state", + workspacePath: "/clone/project-a", + commonDirectory: "/clone/project-a/.git", + gitDirectory: "/clone/project-a/.git", + head: "branch", + branch: "main", + commit: "commit-a", + trackedIdentityMarker: true, + }, + { kind: "identity-claim", scope: "project", id: "project-from-marker", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/clone/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-clone", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-clone" }, + { + target: "git-config", + operation: "create", + id: "context-clone-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-clone-main-default" }, + { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], + details: { + project_identity_storage: "git-local", + tracked_marker_ignored: true, + tracked_marker_mutated: false, + git_index_mutated: false, + }, + output: { + human: { + summary: "Created main/default without inheriting the tracked marker", + fields: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stack: "default", + }, + }, + json: { + outcome: "create", + project_id: "project-clone", + checkout_id: "checkout-clone", + context_id: "context-clone-main", + stack_id: "stack-clone-main-default", + tracked_marker_ignored: true, + }, + }, + }, + }, + { + id: "identity.folder-to-git-exact-claim-preserves-identity", + title: "Folder-to-Git conversion preserves one exact live path claim", + area: "identity", + given: [ + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "identity-claim", + scope: "project", + id: "project-a", + path: "/work/project-a", + status: "exact", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "exact", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-a", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-a" }, + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { git_index_mutated: false }, + output: { + human: { + summary: "Started main/default after converting the folder to Git", + fields: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stack: "default", + }, + }, + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + converted_to_git: true, + }, + }, + }, + }, + { + id: "identity.folder-to-git-without-claim-creates-git-identity", + title: "Folder-to-Git conversion without a live claim creates Git-owned identities", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-git-default"), + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "git-state", + workspacePath: "/work/project-a", + commonDirectory: "/work/project-a/.git", + gitDirectory: "/work/project-a/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { kind: "identity-claim", scope: "project", id: "project-folder", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-git", + checkoutId: "checkout-git", + contextId: "context-git-main", + stackId: "stack-git-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-git", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-git" }, + { + target: "git-config", + operation: "create", + id: "context-git-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-git-default" }, + { target: "managed-state", operation: "create", id: "stack-git-default" }, + { target: "runtime-state", operation: "start", id: "stack-git-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-git-default" }], + details: { project_identity_storage: "git-local", git_index_mutated: false }, + output: { + human: { + summary: "Created main/default with fresh Git identity", + fields: { + projectId: "project-git", + checkoutId: "checkout-git", + contextId: "context-git-main", + stackId: "stack-git-default", + stack: "default", + }, + }, + json: { + outcome: "create", + project_id: "project-git", + checkout_id: "checkout-git", + converted_to_git: true, + }, + }, + }, + }, + { + id: "identity.folder-to-git-ambiguous-claim-fails", + title: "Folder-to-Git conversion fails on ambiguous live identity claims", + area: "identity", + given: [ + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "identity-claim", + scope: "project", + id: "project-folder", + path: "/work/project-a", + status: "ambiguous", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "error", + error: { + code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY", + message: "Multiple live claims can preserve the folder identity", + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, + writes: [], + runtimeEffects: [], + details: { git_index_mutated: false }, + output: { + human: { + summary: "Cannot choose a folder identity for the Git repository", + fields: { code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY" }, + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, + json: { + outcome: "error", + code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY", + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, + }, + }, + }, + { + id: "identity.bare-repository-linked-worktrees-share-project", + title: "Bare-repository worktrees share common project identity without a primary worktree", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-b-main-default"), + { kind: "workspace", mode: "bare-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "bare-worktree", path: "worktree-b" }, + { + kind: "git-state", + workspacePath: "worktree-b", + commonDirectory: "repo.git", + gitDirectory: "repo.git/worktrees/worktree-b", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { kind: "checkout", path: "worktree-a", projectId: "project-bare", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-bare", checkoutId: "checkout-b" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-bare", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + details: { + project_identity_location: "repo.git", + checkout_identity_location: "repo.git/worktrees/worktree-b", + }, + output: { + api: { + projectId: "project-bare", + checkoutId: "checkout-b", + contextId: "context-main", + primaryWorktreeRequired: false, + }, + }, + }, + }, +]); + +const additionalPortContractFixtures = defineManagedStackContractFixtures([ + { + id: "ports.exact-default-value-differs-from-omitted-default", + title: "A present default port is exact while the same omitted default is automatic", + area: "ports", + given: [ + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, + ], + when: { + interface: "managed-api", + method: "resolvePortIntents", + input: { + config: { "api.port": 54321 }, + decodedDefaults: { "api.port": 54321, "db.port": 54322 }, + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + output: { + api: { + "api.port": { intent: "exact", port: 54321, source: "local" }, + "db.port": { intent: "automatic", source: "omitted" }, + }, + }, + }, + }, + { + id: "ports.env-and-remote-values-remain-exact", + title: "Environment-backed and selected remote ports remain exact after resolution", + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + source: "environment", + }, + { kind: "config-port", key: "db.port", intent: "exact", value: 55322, source: "remote" }, + ], + when: { + interface: "managed-api", + method: "resolvePortIntents", + input: { effectiveConfig: { "api.port": 55321, "db.port": 55322 } }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + output: { + api: { + "api.port": { intent: "exact", port: 55321, source: "environment" }, + "db.port": { intent: "exact", port: 55322, source: "remote" }, + }, + }, + }, + }, + { + id: "ports.explicit-free-port-is-used", + title: "A free declarative port is used exactly", + area: "ports", + given: [ + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-main-default", + portIntents: { "api.port": { intent: "exact", port: 54321 } }, + }, + }, + expected: { + outcome: "update", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + api: { + outcome: "update", + stackId: "stack-main-default", + ports: { api: 54321 }, + intent: "exact", + }, + }, + }, + }, + { + id: "ports.new-target-allocates-and-persists-omitted-ports", + title: "A new target allocates and persists host-wide ports for omitted keys", + area: "ports", + given: [ + ...freshManagedStartFacts("stack-feat-default"), + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-feat-default", + portIntents: { "api.port": "automatic", "db.port": "automatic" }, + }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "registry", operation: "publish", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { host_wide: true, sticky: true }, + output: { + api: { + outcome: "create", + stackId: "stack-feat-default", + ports: { api: 55421, db: 55422 }, + intents: { api: "automatic", db: "automatic" }, + }, + }, + }, + }, + { + id: "ports.sibling-targets-allocate-independent-ports", + title: "A new sibling target allocates around existing host-wide port ownership", + area: "ports", + given: [ + ...freshManagedStartFacts("stack-feat-default"), + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + { + kind: "port-assignment", + stackId: "stack-main-review", + key: "db.port", + port: 55422, + intent: "automatic", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-feat-default", + portIntents: { "api.port": "automatic", "db.port": "automatic" }, + }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "registry", operation: "publish", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { + host_wide: true, + sticky: true, + avoided_sibling_stack_ids: ["stack-main-default", "stack-main-review"], + }, + output: { + api: { + outcome: "create", + stackId: "stack-feat-default", + ports: { api: 55423, db: 55424 }, + intents: { api: "automatic", db: "automatic" }, + }, + }, + }, + }, + { + id: "ports.sticky-ports-reuse-on-return", + title: "Returning to an existing target reuses its sticky automatic ports", + area: "ports", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { + kind: "port-assignment", + stackId: "stack-feat-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { + human: { summary: "Started feat-a/default", fields: { apiUrl: "http://127.0.0.1:55421" } }, + json: { + outcome: "reuse", + stack_id: "stack-feat-default", + ports: { api: 55421 }, + sticky: true, + }, + }, + }, + }, + { + id: "ports.later-sticky-port-collision-fails", + title: "A later collision on a sticky automatic port fails without relocation", + area: "ports", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + { + kind: "port-assignment", + stackId: "stack-feat-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + { kind: "occupied-port", port: 55421, owner: "external-process" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + error: { + code: "STICKY_PORT_OCCUPIED", + message: "stack-feat-default owns sticky api.port 55421, but it is in use", + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot start default because its sticky port is occupied", + fields: { code: "STICKY_PORT_OCCUPIED", stackId: "stack-feat-default", port: "55421" }, + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], + }, + json: { + outcome: "error", + code: "STICKY_PORT_OCCUPIED", + stack_id: "stack-feat-default", + port: 55421, + config_key: "api.port", + relocated: false, + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], + }, + }, + }, + }, + { + id: "ports.config-change-on-stopped-stack-applies", + title: "Changing an exact port on a stopped stack applies on next start", + area: "ports", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + previousValue: 54321, + source: "local", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { summary: "Started main/default", fields: { apiUrl: "http://127.0.0.1:55321" } }, + json: { + outcome: "update", + stack_id: "stack-main-default", + previous_port: 54321, + port: 55321, + }, + }, + }, + }, + { + id: "ports.config-change-on-running-stack-reports-drift", + title: "Changing an exact port on a running stack reports drift", + area: "ports", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + previousValue: 54321, + source: "local", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: mainDefaultSelection, + warning: { + code: "RUNNING_STACK_CONFIG_DRIFT", + message: "api.port is running on 54321 but config requires 55321", + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "main/default is running with unapplied port configuration", + fields: { + stackId: "stack-main-default", + configKey: "api.port", + runningPort: "54321", + configuredPort: "55321", + drift: "true", + }, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + json: { + outcome: "report", + code: "RUNNING_STACK_CONFIG_DRIFT", + stack_id: "stack-main-default", + config_key: "api.port", + running_port: 54321, + requested_port: 55321, + drift: true, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + }, + }, + }, + { + id: "ports.removing-exact-key-keeps-current-port-sticky", + title: "Removing an exact key keeps the current port as sticky automatic state", + area: "ports", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "config-port", + key: "api.port", + intent: "automatic", + previousValue: 54321, + source: "omitted", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", portIntents: { "api.port": "automatic" } }, + }, + expected: { + outcome: "update", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { sibling_allocation_independent: true }, + output: { + api: { + stackId: "stack-main-default", + ports: { api: 54321 }, + intents: { api: "automatic" }, + sticky: true, + }, + }, + }, + }, + { + id: "ports.running-legacy-source-fails-before-allocation", + title: "A running legacy source fails before bootstrap or port allocation", + area: "ports", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { kind: "occupied-port", port: 54321, owner: "legacy-stack", ownerId: "legacy-project-a" }, + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "LEGACY_SOURCE_RUNNING", + message: "The matching legacy stack is still running on api.port 54321", + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + details: { + allocation_attempted: false, + legacy_source_stopped: false, + managed_target_published: false, + partial_state: false, + }, + output: { + human: { + summary: "Cannot start while the matching legacy stack is running", + fields: { code: "LEGACY_SOURCE_RUNNING", port: "54321" }, + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], + }, + json: { + outcome: "error", + code: "LEGACY_SOURCE_RUNNING", + port: 54321, + config_key: "api.port", + allocation_attempted: false, + legacy_source_stopped: false, + managed_target_published: false, + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], + }, + }, + }, + }, +]); + +const nativeServiceNames = managedNativeServiceMatrix.services.map(([service]) => service); + +const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ + { + id: "runtime.explicit-api-overrides-auto", + title: "An explicit managed-API runtime overrides the default automatic selection", + area: "runtime", + given: [ + ...freshMainManagedStartFacts, + { kind: "runtime-request", source: "managed-api", runtime: "native" }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "native" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "native", source: "managed-api", stack_wide: true }, + output: { + api: { stackId: "stack-main-default", runtime: "native", runtimeSource: "managed-api" }, + }, + }, + }, + { + id: "runtime.config-overrides-default-auto", + title: "A config runtime overrides automatic selection when no explicit override exists", + area: "runtime", + given: [ + ...mainCheckoutContextFacts, + ...freshMainManagedStartFacts, + { kind: "runtime-request", source: "config", runtime: "native" }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "native", source: "config" }, + output: { + human: { + summary: "Started main/default with native runtime", + fields: { runtime: "native" }, + }, + json: { + outcome: "create", + stack_id: "stack-main-default", + runtime: "native", + runtime_source: "config", + }, + }, + }, + }, + { + id: "runtime.explicit-and-config-conflict-fails", + title: "Conflicting explicit and config runtimes fail before services start", + area: "runtime", + given: [ + { kind: "runtime-request", source: "cli", runtime: "docker" }, + { kind: "runtime-request", source: "config", runtime: "native" }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "docker"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "RUNTIME_SELECTION_CONFLICT", + message: "CLI requests docker while config.toml requests native", + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Conflicting runtime selections", + fields: { + code: "RUNTIME_SELECTION_CONFLICT", + cliRuntime: "docker", + configRuntime: "native", + }, + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, + json: { + outcome: "error", + code: "RUNTIME_SELECTION_CONFLICT", + cli_runtime: "docker", + config_runtime: "native", + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, + }, + }, + }, + { + id: "runtime.auto-prefers-docker", + title: "Automatic selection prefers usable Docker", + area: "runtime", + given: [ + ...freshMainManagedStartFacts, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "docker", available: true }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "auto" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "docker", persisted: true }, + output: { api: { stackId: "stack-main-default", runtime: "docker", runtimeSource: "auto" } }, + }, + }, + { + id: "runtime.auto-selects-fully-qualified-native", + title: + "Automatic selection uses native only when Docker is unusable and the full graph qualifies", + area: "runtime", + given: [ + ...freshMainManagedStartFacts, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { kind: "runtime-availability", runtime: "native", available: true }, + { + kind: "native-qualification", + platform: "darwin-arm64", + qualifiedServices: nativeServiceNames, + failedServices: [], + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "auto" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { + resolved_runtime: "native", + qualified_service_count: nativeServiceNames.length, + mixed_runtime: false, + persisted: true, + }, + output: { + api: { + stackId: "stack-main-default", + runtime: "native", + qualifiedServiceCount: nativeServiceNames.length, + }, + }, + }, + }, + { + id: "runtime.auto-fails-when-neither-runtime-is-available", + title: "Automatic selection reports both availability failures", + area: "runtime", + given: [ + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { + kind: "runtime-availability", + runtime: "native", + available: false, + reason: "platform graph not qualified", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "NO_RUNTIME_AVAILABLE", + message: "Neither Docker nor native can run this stack", + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "No runtime is available", + fields: { docker: "daemon unavailable", native: "platform graph not qualified" }, + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], + }, + json: { + outcome: "error", + code: "NO_RUNTIME_AVAILABLE", + docker_reason: "daemon unavailable", + native_reason: "platform graph not qualified", + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], + }, + }, + }, + }, + { + id: "runtime.explicit-runtime-is-strict", + title: "An explicit runtime fails strictly when its prerequisite is missing", + area: "runtime", + given: [ + { kind: "runtime-request", source: "cli", runtime: "docker" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "docker"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "DOCKER_UNAVAILABLE", + message: "Docker was explicitly requested but its daemon is unavailable", + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], + }, + writes: [], + runtimeEffects: [], + details: { fallback_attempted: false }, + output: { + human: { + summary: "Docker is unavailable", + fields: { code: "DOCKER_UNAVAILABLE", requestedRuntime: "docker" }, + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], + }, + json: { + outcome: "error", + code: "DOCKER_UNAVAILABLE", + requested_runtime: "docker", + reason: "daemon unavailable", + fallback_attempted: false, + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], + }, + }, + }, + }, + { + id: "runtime.persisted-runtime-reused-for-auto", + title: "An existing stack reuses its persisted runtime for omitted or automatic selection", + area: "runtime", + given: [ + ...mainCheckoutContextFacts, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "docker", available: true }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { runtime: "native", auto_re_evaluated: false }, + output: { + human: { + summary: "Started main/default with its persisted runtime", + fields: { stackId: "stack-main-default", stack: "default", runtime: "native" }, + }, + json: { + outcome: "reuse", + stack_id: "stack-main-default", + runtime: "native", + persisted: true, + }, + }, + }, + }, + { + id: "runtime.missing-persisted-prerequisite-fails", + title: "A missing prerequisite for the persisted runtime fails without switching", + area: "runtime", + given: [ + ...mainCheckoutContextFacts, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "native", + available: false, + reason: "artifact missing", + }, + { kind: "runtime-availability", runtime: "docker", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + selection: mainDefaultSelection, + error: { + code: "PERSISTED_RUNTIME_UNAVAILABLE", + message: "stack-main-default uses native, but a required artifact is missing", + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], + }, + writes: [], + runtimeEffects: [], + details: { switched_to_docker: false }, + output: { + human: { + summary: "The persisted native runtime is unavailable", + fields: { code: "PERSISTED_RUNTIME_UNAVAILABLE", stackId: "stack-main-default" }, + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], + }, + json: { + outcome: "error", + code: "PERSISTED_RUNTIME_UNAVAILABLE", + stack_id: "stack-main-default", + runtime: "native", + reason: "artifact missing", + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], + }, + }, + }, + }, + { + id: "runtime.status-reports-one-stack-wide-runtime", + title: "Status reports one persisted stack-wide runtime and any drift", + area: "runtime", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "docker" }, + { kind: "runtime-request", source: "config", runtime: "native" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: mainDefaultSelection, + warning: { + code: "RUNNING_STACK_RUNTIME_DRIFT", + message: "stack-main-default runs with docker but config requests native", + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], + }, + writes: [], + runtimeEffects: [], + details: { mixed_runtime: false }, + output: { + human: { + summary: "main/default is running with Docker", + fields: { runtime: "docker", configuredRuntime: "native", drift: "true" }, + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], + }, + json: { + outcome: "report", + code: "RUNNING_STACK_RUNTIME_DRIFT", + stack_id: "stack-main-default", + runtime: "docker", + configured_runtime: "native", + drift: true, + services: { runtime: "docker" }, + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], + }, + }, + }, + }, + { + id: "native-qualification.all-services-qualify-platform", + title: `A platform is native-supported only when all ${nativeServiceNames.length} services qualify`, + area: "native-qualification", + given: [ + { + kind: "native-qualification", + platform: "darwin-arm64", + qualifiedServices: nativeServiceNames, + failedServices: [], + }, + ], + when: { + interface: "managed-api", + method: "preflightNative", + input: { platform: "darwin-arm64" }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { + qualified: true, + qualified_service_count: nativeServiceNames.length, + failed_service_count: 0, + }, + output: { api: { platform: "darwin-arm64", qualified: true, services: nativeServiceNames } }, + }, + }, + { + id: "native-qualification.one-service-failure-disables-platform", + title: "One failed service disables native mode for the whole platform", + area: "native-qualification", + given: [ + { + kind: "native-qualification", + platform: "linux-amd64", + qualifiedServices: nativeServiceNames.filter((service) => service !== "imgproxy"), + failedServices: ["imgproxy"], + }, + ], + when: { + interface: "managed-api", + method: "preflightNative", + input: { platform: "linux-amd64" }, + }, + expected: { + outcome: "error", + error: { + code: "NATIVE_PLATFORM_NOT_QUALIFIED", + message: "linux-amd64 is missing qualification for imgproxy", + recovery: ["Use Docker", "Complete imgproxy qualification for linux-amd64"], + }, + writes: [], + runtimeEffects: [], + details: { + qualified: false, + qualified_service_count: nativeServiceNames.length - 1, + failed_service_count: 1, + reduced_graph: false, + docker_fallback_per_service: false, + }, + output: { + api: { + platform: "linux-amd64", + qualified: false, + failedServices: ["imgproxy"], + availableServices: [], + }, + }, + }, + }, + { + id: "native-qualification.unsupported-platform-fails-preflight", + title: "An unsupported native platform fails deterministic preflight", + area: "native-qualification", + given: [ + { kind: "runtime-request", source: "cli", runtime: "native" }, + { + kind: "native-qualification", + platform: "darwin-x64", + qualifiedServices: [], + failedServices: nativeServiceNames, + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "NATIVE_PLATFORM_UNSUPPORTED", + message: "Native mode is not qualified on darwin-x64", + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Native mode is unsupported on darwin-x64", + fields: { code: "NATIVE_PLATFORM_UNSUPPORTED", platform: "darwin-x64" }, + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], + }, + json: { + outcome: "error", + code: "NATIVE_PLATFORM_UNSUPPORTED", + platform: "darwin-x64", + supported_platforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], + }, + }, + }, + }, +]); + +const selectorConflictFixture = ( + id: string, + title: string, + selectors: ReadonlyArray, + selectorSummary: string, +): ManagedStackContractScenario => ({ + id, + title, + area: "reclamation", + given: [{ kind: "managed-record", stackId: "stack-main-default", status: "active" }], + when: { + interface: "cli", + argv: ["stop", "--experimental", ...selectors], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "MUTUALLY_EXCLUSIVE_STACK_SELECTORS", + message: "Choose exactly one of contextual, --stack, --stack-id, or --all selection", + recovery: ["Remove all but one stack selector"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Stack selectors cannot be combined", + fields: { selectors: selectorSummary }, + recovery: ["Remove all but one stack selector"], + }, + json: { + outcome: "error", + code: "MUTUALLY_EXCLUSIVE_STACK_SELECTORS", + selectors: selectorSummary.split(", "), + recovery: ["Remove all but one stack selector"], + }, + }, + }, +}); + +const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ + { + id: "bootstrap.existing-managed-target-ignores-legacy", + title: "An existing managed target starts without reading legacy state", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "reuse", + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { legacy_state_read: false, legacy_state_mutated: false }, + output: { + api: { stackId: "stack-main-default", bootstrap: "not-attempted", legacyStateRead: false }, + }, + }, + }, + { + id: "bootstrap.incompatible-legacy-starts-fresh", + title: "A first start with incompatible stopped legacy state creates a fresh managed target", + area: "bootstrap", + given: [ + ...mainCheckoutContextFacts, + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "incompatible", + storage: "absent", + credentials: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { + bootstrap: "fresh", + legacy_state: "incompatible", + legacy_state_mutated: false, + }, + output: { + human: { summary: "Created a fresh main/default stack", fields: { bootstrap: "fresh" } }, + json: { + outcome: "create", + stack_id: "stack-main-default", + bootstrap: "fresh", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "bootstrap.absent-legacy-starts-fresh", + title: "A first start without legacy state creates a fresh managed target", + area: "bootstrap", + given: [ + ...mainCheckoutContextFacts, + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "absent", + database: "absent", + storage: "absent", + credentials: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { bootstrap: "fresh", legacy_state: "absent", legacy_state_mutated: false }, + output: { + human: { summary: "Created a fresh main/default stack", fields: { bootstrap: "fresh" } }, + json: { + outcome: "create", + stack_id: "stack-main-default", + bootstrap: "fresh", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "bootstrap.running-legacy-source-fails-without-mutation", + title: "A running legacy source fails without stopping, copying, or publishing", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "LEGACY_SOURCE_RUNNING", + message: "The legacy stack must be stopped before it can be copied", + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + details: { + legacy_source_stopped: false, + managed_target_published: false, + partial_state: false, + }, + output: { + human: { + summary: "The legacy stack must be stopped before bootstrap", + fields: { code: "LEGACY_SOURCE_RUNNING" }, + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], + }, + json: { + outcome: "error", + code: "LEGACY_SOURCE_RUNNING", + legacy_source_stopped: false, + managed_target_published: false, + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], + }, + }, + }, + }, + { + id: "bootstrap.failed-copy-rolls-back", + title: "A failed bootstrap removes partial managed state before publication", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", injectCopyFailure: true }, + }, + expected: { + outcome: "error", + error: { + code: "LEGACY_BOOTSTRAP_FAILED", + message: "Copying compatible legacy state failed before publication", + recovery: ["Retry the same start command after correcting the copy failure"], + }, + writes: [{ target: "managed-state", operation: "delete", id: "stack-main-default" }], + runtimeEffects: [{ operation: "delete", stackId: "stack-main-default" }], + details: { + active_target_exists: false, + registry_record_published: false, + legacy_state_mutated: false, + }, + output: { + api: { + outcome: "error", + code: "LEGACY_BOOTSTRAP_FAILED", + activeTargetExists: false, + registryRecordPublished: false, + retryable: true, + }, + }, + }, + }, + { + id: "bootstrap.retry-after-failed-copy-succeeds", + title: "The same start succeeds after a failed bootstrap was rolled back", + area: "bootstrap", + given: [ + { + kind: "operation-result", + operation: "legacy-bootstrap", + stackId: "stack-main-default", + outcome: "rolled-back", + }, + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + retry_after_rollback: true, + same_start_request: true, + legacy_state_mutated: false, + }, + output: { + api: { + outcome: "create", + stackId: "stack-main-default", + bootstrap: "copied", + }, + }, + }, + }, + { + id: "bootstrap.managed-and-legacy-diverge-after-copy", + title: "Managed starts never reread legacy state after a successful bootstrap", + area: "bootstrap", + given: [ + ...mainCheckoutContextFacts, + { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "incompatible", + credentials: "incompatible", + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { legacy_state_read: false, legacy_state_mutated: false, timelines_diverged: true }, + output: { + human: { + summary: "Started main/default from independent managed state", + fields: { stackId: "stack-main-default", stack: "default" }, + }, + json: { + outcome: "reuse", + stack_id: "stack-main-default", + bootstrap: "not-attempted", + timelines_diverged: true, + }, + }, + }, + }, + { + id: "credentials.configured-values-are-authoritative", + title: "Configured auth values are authoritative and persist globally only by reference", + area: "credentials", + given: [ + ...freshMainManagedStartFacts, + { kind: "credential-state", source: "configured", valuesId: "configured-auth-v1" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", auth: "configured-auth-v1" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { + credential_values_id: "configured-auth-v1", + source: "configured", + global_credentials_reference: "configured-auth-v1", + plaintext_secrets_in_global_state: false, + }, + output: { + api: { + stackId: "stack-main-default", + credentialsSource: "configured", + credentialsValuesId: "configured-auth-v1", + }, + }, + }, + }, + { + id: "credentials.omitted-values-use-stable-defaults", + title: "Omitted auth values use stable local defaults", + area: "credentials", + given: [ + ...mainCheckoutContextFacts, + ...freshMainManagedStartFacts, + { kind: "credential-state", source: "local-default", valuesId: "stable-local-defaults-v1" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { + credential_values_id: "stable-local-defaults-v1", + generated_per_start: false, + plaintext_secrets_in_global_state: false, + }, + output: { + human: { + summary: "Created main/default with stable local credentials", + fields: { stackId: "stack-main-default", stack: "default" }, + }, + json: { + outcome: "create", + stack_id: "stack-main-default", + credentials_source: "local-default", + credentials_stable: true, + }, + }, + }, + }, + { + id: "credentials.unchanged-values-survive-restart", + title: "Unchanged credential values remain valid across restart", + area: "credentials", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { kind: "credential-state", source: "persisted", valuesId: "stable-local-defaults-v1" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { credential_values_id: "stable-local-defaults-v1", credentials_rotated: false }, + output: { + human: { + summary: "Started main/default with unchanged credentials", + fields: { stackId: "stack-main-default", stack: "default" }, + }, + json: { outcome: "reuse", stack_id: "stack-main-default", credentials_unchanged: true }, + }, + }, + }, + { + id: "credentials.explicit-change-applies-after-stop", + title: "An explicit auth change applies to a stopped stack on next start", + area: "credentials", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "credential-state", + source: "configured", + valuesId: "configured-auth-v2", + previousValuesId: "configured-auth-v1", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { plaintext_secrets_in_global_state: false }, + output: { + human: { + summary: "Updated credentials and started main/default", + fields: { stackId: "stack-main-default", stack: "default" }, + }, + json: { + outcome: "update", + stack_id: "stack-main-default", + previous_credentials_values_id: "configured-auth-v1", + credentials_values_id: "configured-auth-v2", + }, + }, + }, + }, + { + id: "credentials.running-change-reports-drift", + title: "An auth change on a running stack reports unapplied drift", + area: "credentials", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "credential-state", + source: "configured", + valuesId: "configured-auth-v2", + previousValuesId: "configured-auth-v1", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: mainDefaultSelection, + warning: { + code: "RUNNING_STACK_CREDENTIALS_DRIFT", + message: "Configured auth values differ from the running stack", + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "main/default is running with unapplied auth configuration", + fields: { stackId: "stack-main-default", drift: "true" }, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + json: { + outcome: "report", + code: "RUNNING_STACK_CREDENTIALS_DRIFT", + stack_id: "stack-main-default", + drift: true, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + }, + }, + }, + { + id: "credentials.compatible-legacy-auth-is-retained", + title: "Compatible legacy auth configuration is retained during bootstrap", + area: "credentials", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { kind: "credential-state", source: "legacy", valuesId: "legacy-auth-v1" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + credential_values_id: "legacy-auth-v1", + legacy_state_mutated: false, + plaintext_secrets_in_global_state: false, + }, + output: { + api: { + stackId: "stack-main-default", + bootstrap: "copied", + credentialsValuesId: "legacy-auth-v1", + }, + }, + }, + }, + { + id: "credentials.plaintext-secrets-stay-out-of-global-state", + title: "Resolved plaintext secrets are absent from the global managed registry", + area: "credentials", + given: [ + { + kind: "credential-state", + source: "persisted", + valuesId: "configured-auth-v1", + plaintextPresentInGlobalState: false, + }, + { kind: "managed-record", stackId: "stack-main-default", status: "active" }, + ], + when: { + interface: "managed-api", + method: "inspectGlobalRecord", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { plaintext_secrets_present: false }, + output: { + api: { + stackId: "stack-main-default", + credentialsReference: "configured-auth-v1", + plaintextSecrets: [], + }, + }, + }, + }, + { + id: "reclamation.default-stop-preserves-data", + title: "Default experimental stop preserves managed data", + area: "reclamation", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { interface: "cli", argv: ["stop", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "update", id: "stack-main-default" }], + runtimeEffects: [{ operation: "stop", stackId: "stack-main-default" }], + details: { data_preserved: true, registry_record_preserved: true }, + output: { + human: { summary: "Stopped main/default", fields: { dataPreserved: "true" } }, + json: { outcome: "update", stack_id: "stack-main-default", data_preserved: true }, + }, + }, + }, + { + id: "reclamation.delete-repeat-is-idempotent", + title: "Repeating global deletion of a tombstoned stack is a successful no-op", + area: "reclamation", + given: [{ kind: "managed-record", stackId: "stack-orphan", status: "tombstoned" }], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + cwd: "outside-any-checkout", + }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { tombstoned: true, idempotent: true }, + output: { + human: { + summary: "Stack stack-orphan was already deleted", + fields: { stackId: "stack-orphan" }, + }, + json: { + outcome: "no-op", + stack_id: "stack-orphan", + tombstoned: true, + already_deleted: true, + }, + }, + }, + }, + { + id: "reclamation.branch-delete-does-not-delete-data", + title: "Deleting a Git branch alone never deletes its mutable stack data", + area: "reclamation", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: false }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-main", + }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { interface: "git", argv: ["branch", "-D", "feat-a"], cwd: "checkout-a" }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { + managed_command_ran: false, + stack_data_preserved: true, + stack_orphaned: true, + orphaned_stack_id: "stack-feat-default", + }, + output: { human: { summary: "Deleted branch feat-a", fields: {} } }, + }, + }, + { + id: "reclamation.prune-removes-metadata-only", + title: "Prune removes orphan metadata without deleting mutable stack data", + area: "reclamation", + given: [ + { kind: "managed-record", stackId: "stack-orphan", status: "orphaned" }, + { + kind: "stack", + name: "default", + stackId: "stack-orphan", + checkoutId: "checkout-orphan", + contextId: "context-orphan", + lifecycle: "stopped", + orphaned: true, + }, + ], + when: { interface: "cli", argv: ["stack", "prune", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + writes: [{ target: "registry", operation: "delete", id: "stack-orphan" }], + runtimeEffects: [], + details: { metadata_removed: true, mutable_data_deleted: false }, + output: { + human: { summary: "Pruned 1 orphaned metadata record", fields: { dataDeleted: "false" } }, + json: { + outcome: "update", + pruned_records: ["stack-orphan"], + pruned_count: 1, + mutable_data_deleted: false, + }, + }, + }, + }, + selectorConflictFixture( + "reclamation.selectors-stack-and-stack-id-conflict", + "Named and global-ID stack selectors cannot be combined", + ["--stack", "review", "--stack-id", "stack-main-default"], + "--stack, --stack-id", + ), + selectorConflictFixture( + "reclamation.selectors-stack-and-all-conflict", + "Named and all-stack selectors cannot be combined", + ["--stack", "review", "--all"], + "--stack, --all", + ), + selectorConflictFixture( + "reclamation.selectors-stack-id-and-all-conflict", + "Global-ID and all-stack selectors cannot be combined", + ["--stack-id", "stack-main-default", "--all"], + "--stack-id, --all", + ), + { + id: "reclamation.stop-is-engine-scoped", + title: "Experimental stop affects the selected managed stack and never the legacy engine", + area: "reclamation", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { interface: "cli", argv: ["stop", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "update", id: "stack-main-default" }], + runtimeEffects: [{ operation: "stop", stackId: "stack-main-default" }], + details: { + managed_stack_stopped: true, + legacy_stack_stopped: false, + legacy_state_mutated: false, + data_preserved: true, + registry_record_preserved: true, + }, + output: { + human: { summary: "Stopped main/default", fields: { dataPreserved: "true" } }, + json: { + outcome: "update", + stack_id: "stack-main-default", + managed_stack_stopped: true, + legacy_stack_stopped: false, + data_preserved: true, + }, + }, + }, + }, +]); + +const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures([ + { + id: "api-boundary.managed-api-accepts-injected-repository", + title: "The managed API accepts an injected repository without CLI ownership", + area: "api-boundary", + given: [ + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + stateRootPath: "/tmp/managed-contract", + repository: "injected", + repositoryId: "test-repository", + runtime: "node", + }, + ], + when: { + interface: "managed-api", + method: "createManagedStackService", + input: { repository: "test-repository", stateRoot: "/tmp/managed-contract" }, + }, + expected: { + outcome: "create", + writes: [{ target: "ephemeral-state", operation: "create", id: "test-repository" }], + runtimeEffects: [], + details: { cli_required: false, repository_injected: true }, + output: { + api: { + service: "managed-stack-service", + repository: "test-repository", + cliRequired: false, + }, + }, + }, + }, + { + id: "api-boundary.managed-api-accepts-isolated-state-root", + title: "The managed API can run against an isolated caller-provided state root", + area: "api-boundary", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { kind: "identity-claim", scope: "project", id: "project-a", status: "absent" }, + { kind: "identity-claim", scope: "checkout", id: "checkout-a", status: "absent" }, + { kind: "identity-claim", scope: "context", id: "context-main", status: "absent" }, + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + stateRootPath: "/tmp/managed-contract", + repository: "in-memory", + runtime: "bun", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", stateRoot: "/tmp/managed-contract" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-a", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-a" }, + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "managed-state", operation: "create", id: "stack-main-default" }, + ], + runtimeEffects: [], + details: { + state_root: "/tmp/managed-contract", + project_identity_storage: "git-local", + default_system_state_mutated: false, + }, + output: { + api: { projectId: "project-a", checkoutId: "checkout-a", stackId: "stack-main-default" }, + }, + }, + }, + { + id: "api-boundary.repository-contract-is-storage-agnostic", + title: "The same repository contract produces identical decisions across storage adapters", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "node", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "persistent-adapter", + runtime: "node", + }, + ], + when: { + interface: "managed-api", + method: "runRepositoryContract", + input: { + adapters: ["in-memory", "persistent-adapter"], + scenarioId: "identity.return-to-branch-reuses-stack", + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { decisions_equal: true, persistence_semantics_leaked: false }, + output: { + api: { + "in-memory": { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + "persistent-adapter": { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + equal: true, + }, + }, + }, + }, + { + id: "api-boundary.cli-projects-shared-managed-results", + title: "The CLI projects one shared managed result instead of deciding identity twice", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "default", + repository: "persistent-adapter", + runtime: "bun", + }, + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { kind: "managed-record", stackId: "stack-main-default", status: "active" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "docker" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + details: { identity_decisions_in_cli: 0, managed_result_projected: true }, + output: { + human: { + summary: "main/default is running", + fields: { stackId: "stack-main-default", runtime: "docker" }, + }, + json: { + outcome: "report", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + runtime: "docker", + }, + }, + }, + }, + { + id: "api-boundary.managed-surface-is-node-and-bun-portable", + title: "The managed service contract has the same public result under Node and Bun", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "node", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "bun", + }, + ], + when: { + interface: "managed-api", + method: "runPortableContract", + input: { + runtimes: ["node", "bun"], + scenarioId: "identity.same-checkout-branch-and-name-reuses-stack", + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { results_equal: true, bun_specific_state_api: false }, + output: { + api: { + node: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + bun: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + equal: true, + }, + }, + }, + }, +]); + +export const managedStackContractFixtures = defineManagedStackContractFixtures([ + ...additionalIdentityContractFixtures, + ...additionalPortContractFixtures, + ...additionalRuntimeContractFixtures, + ...additionalLifecycleContractFixtures, + ...additionalApiBoundaryContractFixtures, + { + id: "identity.return-to-branch-reuses-stack", + title: "Returning to a previously used branch reuses its stack", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: true, + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Reused main/default", + fields: { + branch: "main", + stack: "default", + stackId: "stack-main-default", + }, + }, + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + stack_name: "default", + }, + }, + }, + }, + { + id: "identity.branch-copy-ambiguous-read-only", + title: "An ambiguous copied branch is reported without mutation", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: false, + }, + { + kind: "branch", + name: "feat-copy", + contextId: "context-main", + checkedOut: true, + }, + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + status: "ambiguous", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "AMBIGUOUS_CONTEXT_OWNER", + message: "Branches feat-copy and main both claim context-main", + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot determine which branch owns this stack context", + fields: { + contextId: "context-main", + branches: "feat-copy, main", + }, + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + json: { + outcome: "error", + code: "AMBIGUOUS_CONTEXT_OWNER", + context_id: "context-main", + branches: ["feat-copy", "main"], + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + }, + }, + }, + { + id: "ports.explicit-port-conflict-fails", + title: "An occupied declarative port fails without relocation", + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + }, + { + kind: "occupied-port", + port: 54321, + owner: "external-process", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "EXACT_PORT_OCCUPIED", + message: "api.port requires 54321, but that port is already in use", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot start because configured port 54321 is in use", + fields: { + port: "54321", + configKey: "api.port", + owner: "external-process", + }, + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + json: { + outcome: "error", + code: "EXACT_PORT_OCCUPIED", + port: 54321, + config_key: "api.port", + owner: "external-process", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + }, + }, + }, + { + id: "ports.explicit-port-conflict-with-sibling-fails", + title: "A sibling managed stack holding a declarative port is identified precisely", + area: "ports", + given: [ + { + kind: "checkout", + path: "worktree-feat-a", + projectId: "project-a", + checkoutId: "checkout-feat-a", + }, + { kind: "branch", name: "feat-a", contextId: "context-feat-a", checkedOut: true }, + { kind: "managed-target", stackId: "stack-feat-a-default", exists: false }, + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + source: "local", + }, + { + kind: "occupied-port", + port: 54321, + owner: "managed-stack", + ownerId: "stack-main-default", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "worktree-feat-a", + }, + expected: { + outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-feat-a", + contextId: "context-feat-a", + stackId: "stack-feat-a-default", + stackName: "default", + }, + error: { + code: "EXACT_PORT_OCCUPIED", + message: "api.port requires 54321, but stack-main-default already owns that port", + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot start because a sibling stack uses configured port 54321", + fields: { + port: "54321", + configKey: "api.port", + owner: "managed-stack", + ownerStackId: "stack-main-default", + }, + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + json: { + outcome: "error", + code: "EXACT_PORT_OCCUPIED", + port: 54321, + config_key: "api.port", + owner: "managed-stack", + owner_stack_id: "stack-main-default", + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + }, + }, + }, + { + id: "runtime.persisted-runtime-conflict-fails", + title: "An existing stack cannot be switched to another runtime by start", + area: "runtime", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + { + kind: "runtime-request", + source: "cli", + runtime: "native", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + error: { + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", + message: "stack-main-default uses docker, but start requested native", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot change the runtime of an existing stack", + fields: { + stackId: "stack-main-default", + persistedRuntime: "docker", + requestedRuntime: "native", + }, + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + json: { + outcome: "error", + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", + stack_id: "stack-main-default", + persisted_runtime: "docker", + requested_runtime: "native", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + }, + }, + }, + { + id: "bootstrap.first-start-copies-compatible-legacy-state", + title: "First experimental start copies compatible stopped legacy state", + area: "bootstrap", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "managed-target", + stackId: "stack-main-default", + exists: false, + }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + bootstrap: "copied", + legacy_state_mutated: false, + credentials: "preserved", + }, + output: { + human: { + summary: "Created main/default from compatible legacy state", + fields: { + stack: "default", + stackId: "stack-main-default", + bootstrap: "copied", + credentials: "preserved", + }, + }, + json: { + outcome: "create", + bootstrap: "copied", + stack_id: "stack-main-default", + credentials: "preserved", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "reclamation.delete-orphan-by-stack-id", + title: "An orphaned stack can be deleted globally by opaque ID", + area: "reclamation", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-orphan", + checkoutId: "checkout-orphan", + contextId: "context-orphan", + lifecycle: "running", + orphaned: true, + }, + ], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + cwd: "outside-any-checkout", + }, + expected: { + outcome: "delete", + writes: [ + { target: "runtime-state", operation: "delete", id: "stack-orphan" }, + { target: "managed-state", operation: "delete", id: "stack-orphan" }, + { target: "registry", operation: "tombstone", id: "stack-orphan" }, + ], + runtimeEffects: [ + { operation: "stop", stackId: "stack-orphan" }, + { operation: "delete", stackId: "stack-orphan" }, + ], + details: { + tombstoned: true, + checkout_required: false, + }, + output: { + human: { + summary: "Deleted managed stack stack-orphan", + fields: { + stackId: "stack-orphan", + orphaned: "true", + tombstoned: "true", + }, + }, + json: { + outcome: "delete", + stack_id: "stack-orphan", + orphaned: true, + tombstoned: true, + }, + }, + }, + }, + { + id: "api-boundary.direct-create-stack-is-ephemeral", + title: "Direct createStack usage is isolated from managed system state", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "omitted", + runtimeRoot: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { startupMode: "lazy" }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["stack", "runtime"], + }, + output: { api: directStackApiProjection }, + }, + }, + { + id: "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary", + title: "Direct createStack keeps an omitted runtime root temporary", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "explicit", + runtimeRoot: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { + projectDir: "/work/project-a", + cacheRoot: "/work/cache", + stackRoot: "/work/stack", + startupMode: "lazy", + }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["runtime"], + }, + output: { api: directStackApiProjection }, + }, + }, + { + id: "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary", + title: "Direct createStack keeps an omitted stack root temporary", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "omitted", + runtimeRoot: "explicit", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { runtimeRoot: "/work/runtime", startupMode: "lazy" }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["stack"], + }, + output: { api: directStackApiProjection }, + }, + }, + { + id: "api-boundary.direct-dispose-removes-temporary-roots", + title: "Disposing a direct stack removes every omitted temporary root", + area: "api-boundary", + given: [ + { + kind: "direct-stack-state", + handle: "stack-handle", + temporaryRoots: [ + { root: "stack", stateId: "ephemeral-stack-root" }, + { root: "runtime", stateId: "ephemeral-runtime-root" }, + ], + lifecycle: "created", + }, + ], + when: { + interface: "stack-api", + method: "dispose", + input: {}, + }, + expected: { + outcome: "delete", + writes: [ + { + target: "temporary-root", + operation: "delete", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "delete", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + temporary_roots_removed: true, + removed_temporary_roots: ["stack", "runtime"], + }, + output: {}, + }, + }, +]); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 206459eeeb..f2316af105 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1,3 +1,21 @@ /** Test-only service tags for building deterministic consumer layers. */ export { DaemonServer } from "./DaemonServer.ts"; +export type { + ManagedStackContractArea, + ManagedStackContractAction, + ManagedStackContractEffects, + ManagedStackContractExpectation, + ManagedStackContractFact, + ManagedStackContractJson, + ManagedStackContractOutput, + ManagedStackContractScenario, + ManagedNativeServiceMatrix, +} from "./managed-stack-contract.ts"; +export { + managedNativePlatformByNodeTarget, + managedNativePlatformFromNode, + managedNativeServiceMatrix, + managedStackContractFixtures, +} from "./managed-stack-contract.ts"; +export { validateManagedStackContractFixtures } from "./managed-stack-contract-validation.ts"; export { UnixHttpClient } from "./UnixHttpClient.ts";