mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Keep requested branch attachment separate from ownership established by realization, and round-trip the optional intent through the shared request contract.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit ab1e8acc68)
9005 lines
343 KiB
TypeScript
9005 lines
343 KiB
TypeScript
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { executionWorkspaces, issueComments, issues, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
|
import {
|
|
DEFAULT_TAILSCALE_HTTPS_EXPOSURE,
|
|
deriveViteHmrPort,
|
|
forceLoopbackBindInCommand,
|
|
isRuntimeExposureAppPort,
|
|
listWorkspaceServiceCommandDefinitions,
|
|
RUNTIME_EXPOSURE_BIND_HOST,
|
|
RUNTIME_EXPOSURE_BIND_MODE,
|
|
rewriteUrlHostToLoopback,
|
|
readRuntimeExposureIntent,
|
|
resolveDeclaredRuntimeExposureConfig,
|
|
type RuntimeExposureConfigInput,
|
|
type RuntimeExposureIntent,
|
|
type RuntimeExposureStatus,
|
|
type GitWorktreeBranchAncestryVerdict,
|
|
type GitWorktreeBranchIncoherenceEvidence as SharedGitWorktreeBranchIncoherenceEvidence,
|
|
type GitWorktreeInProgressOperation,
|
|
type IssueCommentMetadata,
|
|
type IssueCommentPresentation,
|
|
type WorkspaceOperationPhase,
|
|
type WorkspaceRuntimeDesiredState,
|
|
type WorkspaceRuntimeServiceStateMap,
|
|
} from "@paperclipai/shared";
|
|
import { and, desc, eq, gte, inArray, isNull, lte, ne, or } from "drizzle-orm";
|
|
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
|
|
import { conflict } from "../errors.js";
|
|
import { resolveHomeAwarePath } from "../home-paths.js";
|
|
import { hasVerifiedWorktreeSeedManifest, isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js";
|
|
import {
|
|
buildManagedWorkspaceGuestEnv,
|
|
logManagedWorkspaceReadinessRejection,
|
|
probeManagedWorkspaceHandoffSubjects,
|
|
probeManagedWorkspaceReadiness,
|
|
resolveManagedWorkspaceIdentity,
|
|
shouldBlockPublicationOnReadiness,
|
|
waitForManagedWorkspaceReadiness,
|
|
} from "./managed-workspace-identity.js";
|
|
import {
|
|
createLocalServiceKey,
|
|
findLocalServiceRegistryRecordByRuntimeServiceId,
|
|
findAdoptableLocalService,
|
|
isLocalServiceProcessOwnedBy,
|
|
isLocalServiceProcessInWorkspace,
|
|
openLocalServiceLogFile,
|
|
readLocalServiceProcessCwd,
|
|
readLocalServiceProcessGroupId,
|
|
readLocalServicePortOwner,
|
|
removeLocalServiceRegistryRecord,
|
|
terminateLocalService,
|
|
touchLocalServiceRegistryRecord,
|
|
writeLocalServiceRegistryRecord,
|
|
} from "./local-service-supervisor.js";
|
|
import { workspaceOperationService, type WorkspaceOperationRecorder } from "./workspace-operations.js";
|
|
import { executionWorkspaceService, readExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
|
import { isRuntimeOwnedGitBranch } from "./execution-workspace-branch-ownership.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
|
import { workspaceGitOperationScheduler } from "./workspace-git-operation-scheduler.js";
|
|
import { classifyGitCloneFailure } from "./git-connection-failure.js";
|
|
import { readWorkspaceBaseRefDiagnostic, type WorkspaceBaseRefDiagnostic } from "./workspace-base-ref-diagnostics.js";
|
|
import {
|
|
MANAGED_GIT_WORKTREE_REASON_CODES,
|
|
readManagedGitInspectionDiagnostic,
|
|
type ManagedGitInspectionDiagnostic,
|
|
} from "./workspace-validation-diagnostics.js";
|
|
import {
|
|
cleanupWorktreeInstanceArtifacts,
|
|
deriveWorktreeInstanceId,
|
|
readWorktreeInstancePointer,
|
|
WORKTREE_INSTANCE_ROOT_METADATA_KEY,
|
|
type WorktreeInstancePointer,
|
|
} from "./workspace-instance-cleanup.js";
|
|
import { UnixBrokerClient, type BrokerClient } from "./runtime-exposure/broker-client.js";
|
|
import {
|
|
deprovisionExposure,
|
|
provisionExposure,
|
|
reserveExposure,
|
|
type ExposureManagerDeps,
|
|
} from "./runtime-exposure/exposure-manager.js";
|
|
import { diagnoseRuntimeListenerBinds, readListenerBindFacts } from "./runtime-exposure/loopback-listener.js";
|
|
import { allocateExposurePortPair } from "./runtime-exposure/port-pair.js";
|
|
import {
|
|
buildExposureReservationLedger,
|
|
collectRowExposurePorts,
|
|
describeExposureReservationDrift,
|
|
ExposurePortOwnershipConflictError,
|
|
ExposurePortPairClaims,
|
|
findExposurePairConflict,
|
|
findExposureReservationDrift,
|
|
isExposureAdoptionPermitted,
|
|
type BrokerMappingSnapshot,
|
|
type ExposureOwnerIdentity,
|
|
type ExposureReservationLedger,
|
|
type InMemoryExposureSnapshot,
|
|
type PersistedExposureRowSnapshot,
|
|
} from "./runtime-exposure/port-reservation.js";
|
|
import { resolveTailscaleDnsName } from "./runtime-exposure/tailscale-hostname.js";
|
|
|
|
export function resolveShell(): string {
|
|
const fallback = process.platform === "win32" ? "sh" : "/bin/sh";
|
|
const shell = process.env.SHELL?.trim();
|
|
if (!shell) return fallback;
|
|
if (path.isAbsolute(shell) && !existsSync(shell)) return fallback;
|
|
return shell;
|
|
}
|
|
|
|
/**
|
|
* A read-only referenced (mentioned) project workspace carried alongside the anchor. Additive and
|
|
* backward-compatible: it defaults to an empty array. Additional workspaces never get git-worktree
|
|
* realization; the anchor keeps the single scalar realization path.
|
|
*/
|
|
export interface ExecutionWorkspaceAdditionalInput {
|
|
cwd: string;
|
|
projectId: string;
|
|
workspaceId: string | null;
|
|
repoUrl: string | null;
|
|
repoRef: string | null;
|
|
}
|
|
|
|
export interface ExecutionWorkspaceInput {
|
|
baseCwd: string;
|
|
source: "project_primary" | "task_session" | "agent_home";
|
|
projectId: string | null;
|
|
workspaceId: string | null;
|
|
repoUrl: string | null;
|
|
repoRef: string | null;
|
|
additionalWorkspaces?: ExecutionWorkspaceAdditionalInput[];
|
|
}
|
|
|
|
/**
|
|
* A prepared credential-bearing git invocation for one remote URL, or null to keep ambient
|
|
* behavior. Structurally compatible with the provider built by `git-credentials.ts` — this
|
|
* module deliberately takes prepared invocations rather than tokens, so it never imports the
|
|
* secrets layer and test fakes stay trivial.
|
|
*/
|
|
export type GitRemoteAuthInvocation = {
|
|
configArgs: string[];
|
|
env: Record<string, string>;
|
|
source?: string;
|
|
secretName?: string | null;
|
|
};
|
|
|
|
export type GitRemoteAuthProvider = (remoteUrl: string) => Promise<GitRemoteAuthInvocation | null>;
|
|
|
|
export interface ExecutionWorkspaceIssueRef {
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string | null;
|
|
workMode?: string | null;
|
|
}
|
|
|
|
export interface ExecutionWorkspaceAgentRef {
|
|
id: string | null;
|
|
name: string;
|
|
companyId: string;
|
|
}
|
|
|
|
export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput {
|
|
strategy: "project_primary" | "git_worktree";
|
|
cwd: string;
|
|
branchName: string | null;
|
|
/** Remote realization intent; distinct from ownership established after creation. */
|
|
existingBranch?: string;
|
|
worktreePath: string | null;
|
|
warnings: string[];
|
|
created: boolean;
|
|
// Branch ownership, distinct from `created` (which reports a fresh worktree
|
|
// checkout). True only when this realization created the branch ref itself;
|
|
// attaching a worktree to a pre-existing branch keeps the branch
|
|
// operator-owned. Persisted with versioned branch-ownership metadata, which
|
|
// restore and terminal cleanup use to decide whether the branch may be
|
|
// recreated or deleted.
|
|
branchCreatedByRuntime: boolean;
|
|
baseRefSha?: string | null;
|
|
pendingForwardBranchReconcile?: PendingForwardBranchReconcile | null;
|
|
}
|
|
|
|
export class WorkspaceRuntimeValidationFailure extends Error {
|
|
code = "workspace_validation_failed" as const;
|
|
resultJson: Record<string, unknown>;
|
|
|
|
constructor(message: string, resultJson: Record<string, unknown>) {
|
|
super(message);
|
|
this.name = "WorkspaceRuntimeValidationFailure";
|
|
this.resultJson = resultJson;
|
|
}
|
|
}
|
|
|
|
export interface RuntimeServiceRef {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
projectWorkspaceId: string | null;
|
|
executionWorkspaceId: string | null;
|
|
issueId: string | null;
|
|
serviceName: string;
|
|
status: "provisioning" | "starting" | "running" | "stopped" | "failed";
|
|
lifecycle: "shared" | "ephemeral";
|
|
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
|
scopeId: string | null;
|
|
reuseKey: string | null;
|
|
command: string | null;
|
|
cwd: string | null;
|
|
port: number | null;
|
|
url: string | null;
|
|
provider: "local_process" | "adapter_managed";
|
|
providerRef: string | null;
|
|
ownerAgentId: string | null;
|
|
startedByRunId: string | null;
|
|
lastUsedAt: string;
|
|
startedAt: string;
|
|
stoppedAt: string | null;
|
|
stopPolicy: Record<string, unknown> | null;
|
|
healthStatus: "unknown" | "healthy" | "unhealthy";
|
|
exposure: RuntimeExposureStatus | null;
|
|
reused: boolean;
|
|
}
|
|
|
|
interface RuntimeServiceRecord extends RuntimeServiceRef {
|
|
db?: Db;
|
|
child: ChildProcess | null;
|
|
leaseRunIds: Set<string>;
|
|
idleTimer: ReturnType<typeof globalThis.setTimeout> | null;
|
|
envFingerprint: string;
|
|
serviceKey: string;
|
|
profileKind: string;
|
|
processGroupId: number | null;
|
|
/** Server-private broker lease handle; never returned by toRuntimeServiceRef. */
|
|
exposureHandle: string | null;
|
|
/** Loopback URL used for backend readiness/adoption; never serialized. */
|
|
backendUrl: string | null;
|
|
exposureConfig: RuntimeExposureConfigInput | null;
|
|
}
|
|
|
|
type LocalRuntimeServiceStart = {
|
|
record: RuntimeServiceRecord;
|
|
readiness: Promise<void>;
|
|
};
|
|
|
|
type PendingRuntimeServiceReadiness = LocalRuntimeServiceStart & {
|
|
service: Record<string, unknown>;
|
|
};
|
|
|
|
type StoppedRuntimeServiceReuseCandidate = {
|
|
id: string;
|
|
port: number | null;
|
|
};
|
|
|
|
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
|
const runtimeServicesByReuseKey = new Map<string, string>();
|
|
const runtimeServiceLeasesByRun = new Map<string, string[]>();
|
|
const runtimeProvisionByWorkspace = new Map<string, Promise<void>>();
|
|
const runtimeControlStartByOwner = new Map<string, Promise<void>>();
|
|
const runtimeReplacementClaimsByReuseKey = new Map<string, number>();
|
|
const quarantinedRuntimeExposurePorts = new Set<number>();
|
|
/**
|
|
* Pair-atomic in-process claims for exposure allocations that have not bound a
|
|
* listener yet. Separate from `inFlightAllocatedPorts` (single ports, non-exposed
|
|
* runtimes) because an exposure claim must cover the app port and its HMR
|
|
* companion together or not at all.
|
|
*/
|
|
const exposurePortPairClaims = new ExposurePortPairClaims();
|
|
/**
|
|
* Execution-workspace statuses that still hold an exclusive lease. `archived`
|
|
* is the only terminal state; everything else — including `idle` — is a lane an
|
|
* operator or agent can still return to, so its port pair stays reserved.
|
|
*/
|
|
const OPEN_EXECUTION_WORKSPACE_LEASE_STATUSES = ["active", "idle", "in_review"] as const;
|
|
const DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES = 256 * 1024;
|
|
export const WORKSPACE_RUNTIME_PORT_ALLOCATION_ATTEMPTS = 32;
|
|
const ACTIVE_RUNTIME_PORT_RESERVATION_STATUSES = ["provisioning", "starting", "running"] as const;
|
|
const DEFAULT_TAILSCALE_BROKER_SOCKET = "/run/paperclip-tailscale-broker/broker.sock";
|
|
|
|
class RuntimeServicePortBindCollision extends Error {
|
|
readonly port: number;
|
|
/**
|
|
* Who held the port when the collision was seen, captured at failure time.
|
|
* Null when no owner remained (a transient racer that already released it).
|
|
*/
|
|
readonly diagnosis: string | null;
|
|
|
|
/**
|
|
* True when the port is only a preference and the caller may re-allocate a
|
|
* different one. Exposed runtimes always draw from the dedicated broker range,
|
|
* so a collision on the assigned port is recoverable by taking the next pair.
|
|
*/
|
|
readonly exposureReallocatable: boolean;
|
|
|
|
constructor(port: number, diagnosis: string | null = null, exposureReallocatable = false) {
|
|
super(
|
|
`Runtime service could not bind allocated port ${port}` +
|
|
(diagnosis ? ` (${diagnosis})` : ""),
|
|
);
|
|
this.name = "RuntimeServicePortBindCollision";
|
|
this.port = port;
|
|
this.diagnosis = diagnosis;
|
|
this.exposureReallocatable = exposureReallocatable;
|
|
}
|
|
}
|
|
|
|
export type WorkspaceRuntimeExposureDeps = ExposureManagerDeps & {
|
|
resolveHostname: () => Promise<string>;
|
|
isPortAvailable: (port: number) => Promise<boolean>;
|
|
/**
|
|
* Whether this host can actually broker HTTPS exposures right now. Gating the
|
|
* automatic default on broker availability is what keeps a Paperclip install
|
|
* without the host broker from failing every managed runtime start closed.
|
|
* An explicit opt-in still bypasses this and fails loudly.
|
|
*/
|
|
isBrokerAvailable: () => Promise<boolean>;
|
|
};
|
|
|
|
async function isLoopbackPortAvailable(port: number): Promise<boolean> {
|
|
return await new Promise<boolean>((resolve) => {
|
|
const server = net.createServer();
|
|
server.unref();
|
|
server.once("error", () => resolve(false));
|
|
server.listen(port, "127.0.0.1", () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
});
|
|
}
|
|
|
|
function resolveTailscaleBrokerSocketPath(): string {
|
|
return process.env.PAPERCLIP_TAILSCALE_BROKER_SOCKET?.trim() || DEFAULT_TAILSCALE_BROKER_SOCKET;
|
|
}
|
|
|
|
function defaultWorkspaceRuntimeExposureDeps(): WorkspaceRuntimeExposureDeps {
|
|
const socketPath = resolveTailscaleBrokerSocketPath();
|
|
const broker: BrokerClient = new UnixBrokerClient({ socketPath });
|
|
return {
|
|
broker,
|
|
resolveHostname: () => resolveTailscaleDnsName(),
|
|
isPortAvailable: isLoopbackPortAvailable,
|
|
isBrokerAvailable: async () => {
|
|
try {
|
|
// Presence of the socket, not a probe request: availability is checked
|
|
// on every managed start, and an unauthenticated connect storm against
|
|
// the broker would be its own problem.
|
|
const stats = await fs.stat(socketPath);
|
|
return stats.isSocket();
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
probeHealth: async (url) => {
|
|
try {
|
|
const response = await fetch(url, {
|
|
redirect: "error",
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
now: () => new Date().toISOString(),
|
|
diagnoseListenerBinds: diagnoseRuntimeListenerBinds,
|
|
};
|
|
}
|
|
|
|
let workspaceRuntimeExposureDeps = defaultWorkspaceRuntimeExposureDeps();
|
|
|
|
/** Test-only seam; resetRuntimeServicesForTests restores production defaults. */
|
|
export function setWorkspaceRuntimeExposureDepsForTests(deps: WorkspaceRuntimeExposureDeps) {
|
|
workspaceRuntimeExposureDeps = deps;
|
|
}
|
|
|
|
/**
|
|
* Deployment-level switch for the automatic default (PAP-17158).
|
|
*
|
|
* - `auto` (default): eligible Paperclip-managed worktree runtimes get
|
|
* `tailscale_https` without any project template or UI caller supplying an
|
|
* exposure block, provided the host broker is available.
|
|
* - `off`: no automatic default. Explicit opt-ins still work.
|
|
* - `force`: default even when the broker socket is missing, so a
|
|
* misconfigured host fails closed and loudly instead of silently serving
|
|
* plain HTTP. Intended for deployments that require HTTPS previews.
|
|
*/
|
|
export type ManagedRuntimeHttpsMode = "auto" | "off" | "force";
|
|
|
|
export function resolveManagedRuntimeHttpsMode(): ManagedRuntimeHttpsMode {
|
|
const raw = process.env.PAPERCLIP_MANAGED_RUNTIME_HTTPS?.trim().toLowerCase();
|
|
if (raw === "off" || raw === "false" || raw === "0") return "off";
|
|
if (raw === "force") return "force";
|
|
return "auto";
|
|
}
|
|
|
|
/**
|
|
* Whether a service would be defaulted to HTTPS if it declared nothing.
|
|
*
|
|
* Intentionally narrow: only the Paperclip-managed dev runtime. Unmanaged and
|
|
* custom external services are left exactly as they are, because the broker
|
|
* only publishes allowlisted loopback ports it can prove Paperclip owns and we
|
|
* do not want to relocate a service somebody else addresses by port.
|
|
*
|
|
* A *pinned* port is still a candidate. The pre-feature Paperclip App template
|
|
* hard-codes `port: 45439`, which the broker's dedicated allowlist can never
|
|
* publish, so defaulting it to HTTPS necessarily relocates it into the
|
|
* dedicated range. "Keep existing runtime ports when safe" is honored one layer
|
|
* down, by preferring the current port when it already *is* an allowlisted app
|
|
* port with a free HMR companion.
|
|
*/
|
|
function isManagedHttpsDefaultCandidate(input: {
|
|
serviceName: string;
|
|
command: string | null;
|
|
}): boolean {
|
|
return isPaperclipDevRuntimeService(input);
|
|
}
|
|
|
|
export type ResolvedRuntimeServiceExposure = {
|
|
config: RuntimeExposureConfigInput;
|
|
/**
|
|
* `declared` — the project template or UI caller asked for HTTPS.
|
|
* `default` — the server applied the automatic default (PAP-17158).
|
|
*
|
|
* The distinction matters for port handling: a declared opt-in on a pinned
|
|
* port is an operator misconfiguration and fails loudly, while the automatic
|
|
* default is allowed to relocate a legacy pinned port into the dedicated
|
|
* exposure range.
|
|
*/
|
|
origin: "declared" | "default";
|
|
};
|
|
|
|
/**
|
|
* Resolve the exposure config for one runtime service start.
|
|
*
|
|
* Precedence: deliberate opt-out → explicit opt-in → automatic default for
|
|
* eligible managed runtimes → none.
|
|
*/
|
|
async function resolveRuntimeServiceExposure(input: {
|
|
service: Record<string, unknown>;
|
|
serviceName: string;
|
|
command: string | null;
|
|
}): Promise<ResolvedRuntimeServiceExposure | null> {
|
|
const expose = parseObject(input.service.expose);
|
|
const intent = readRuntimeExposureIntent(expose);
|
|
if (intent === "disabled") return null;
|
|
// An explicit opt-in is honored verbatim and is never gated on broker
|
|
// availability: the operator asked for HTTPS, so a missing broker must fail
|
|
// the start rather than silently downgrade it to HTTP.
|
|
if (intent === "enabled") {
|
|
const declared = resolveDeclaredRuntimeExposureConfig(expose);
|
|
return declared ? { config: declared, origin: "declared" } : null;
|
|
}
|
|
|
|
const mode = resolveManagedRuntimeHttpsMode();
|
|
if (mode === "off") return null;
|
|
if (!isManagedHttpsDefaultCandidate({ serviceName: input.serviceName, command: input.command })) {
|
|
return null;
|
|
}
|
|
if (mode !== "force" && !(await workspaceRuntimeExposureDeps.isBrokerAvailable())) return null;
|
|
return { config: DEFAULT_TAILSCALE_HTTPS_EXPOSURE, origin: "default" };
|
|
}
|
|
|
|
/**
|
|
* Whether any entry in a start batch will take the HTTPS exposure path.
|
|
*
|
|
* Reads the service name and command straight off the raw config entry rather
|
|
* than resolving the full reuse identity: templates never rewrite a service
|
|
* name, and the substrings `isPaperclipDevRuntimeService` matches survive
|
|
* rendering, so this agrees with the per-service decision made during spawn.
|
|
*/
|
|
async function anyRuntimeServiceUsesHttpsExposure(
|
|
services: Record<string, unknown>[],
|
|
): Promise<boolean> {
|
|
for (const service of services) {
|
|
const resolved = await resolveRuntimeServiceExposure({
|
|
service,
|
|
serviceName: asString(service.name, "service"),
|
|
command: asString(service.command, ""),
|
|
});
|
|
if (resolved) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
type ProcessOutputCapture = {
|
|
text: string;
|
|
truncated: boolean;
|
|
totalBytes: number;
|
|
};
|
|
|
|
type ProcessOutputAccumulator = {
|
|
append(chunk: string): void;
|
|
finish(): ProcessOutputCapture;
|
|
};
|
|
|
|
/**
|
|
* Drops in-memory runtime state between tests.
|
|
*
|
|
* By default the spawned backend processes are deliberately left running: the
|
|
* startup-reconciliation suites use this to simulate a Paperclip restart, where
|
|
* the point is that a live backend survives and has to be adopted.
|
|
*
|
|
* Suites that spawn real backends and do *not* need that must pass
|
|
* `terminateProcesses` — otherwise every test leaks a listener that keeps
|
|
* squatting a port in the dedicated exposure range for the life of the host.
|
|
* Termination runs before the exposure deps are restored so the suite's own
|
|
* broker fake handles the removal rather than the real host broker.
|
|
*/
|
|
export async function resetRuntimeServicesForTests(
|
|
opts: { terminateProcesses?: boolean; simulateSupervisorExit?: boolean } = {},
|
|
) {
|
|
if (opts.terminateProcesses) {
|
|
for (const serviceId of [...runtimeServicesById.keys()]) {
|
|
await stopRuntimeService(serviceId).catch(() => undefined);
|
|
}
|
|
}
|
|
for (const record of runtimeServicesById.values()) {
|
|
clearIdleTimer(record);
|
|
if (opts.simulateSupervisorExit) {
|
|
// A real supervisor exit closes its side of every inherited pipe. Tests
|
|
// use this to prove surviving request-logging services do not depend on
|
|
// Paperclip keeping an anonymous stdio peer alive.
|
|
record.child?.stdout?.destroy();
|
|
record.child?.stderr?.destroy();
|
|
}
|
|
}
|
|
runtimeServicesById.clear();
|
|
runtimeServicesByReuseKey.clear();
|
|
runtimeServiceLeasesByRun.clear();
|
|
runtimeProvisionByWorkspace.clear();
|
|
runtimeControlStartByOwner.clear();
|
|
runtimeReplacementClaimsByReuseKey.clear();
|
|
quarantinedRuntimeExposurePorts.clear();
|
|
exposurePortPairClaims.clear();
|
|
workspaceRuntimeExposureDeps = defaultWorkspaceRuntimeExposureDeps();
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const rec = value as Record<string, unknown>;
|
|
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
type WorkspaceLinkMismatch = {
|
|
packageName: string;
|
|
expectedPath: string;
|
|
actualPath: string | null;
|
|
};
|
|
|
|
function readJsonFile(filePath: string): Record<string, unknown> {
|
|
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
|
}
|
|
|
|
function findWorkspaceRoot(startCwd: string) {
|
|
let current = path.resolve(startCwd);
|
|
while (true) {
|
|
if (existsSync(path.join(current, "pnpm-workspace.yaml"))) {
|
|
return current;
|
|
}
|
|
const parent = path.dirname(current);
|
|
if (parent === current) return null;
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
function isLinkedGitWorktreeCheckout(rootDir: string) {
|
|
const gitMetadataPath = path.join(rootDir, ".git");
|
|
if (!existsSync(gitMetadataPath)) return false;
|
|
|
|
const stat = lstatSync(gitMetadataPath);
|
|
if (!stat.isFile()) return false;
|
|
|
|
return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:");
|
|
}
|
|
|
|
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
|
const packagePaths = new Map<string, string>();
|
|
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
|
|
|
function visit(dirPath: string) {
|
|
if (!existsSync(dirPath)) return;
|
|
|
|
const packageJsonPath = path.join(dirPath, "package.json");
|
|
if (existsSync(packageJsonPath)) {
|
|
const packageJson = readJsonFile(packageJsonPath);
|
|
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
|
|
packagePaths.set(packageJson.name, dirPath);
|
|
}
|
|
}
|
|
|
|
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (ignoredDirNames.has(entry.name)) continue;
|
|
visit(path.join(dirPath, entry.name));
|
|
}
|
|
}
|
|
|
|
visit(path.join(rootDir, "packages"));
|
|
visit(path.join(rootDir, "server"));
|
|
visit(path.join(rootDir, "ui"));
|
|
visit(path.join(rootDir, "cli"));
|
|
|
|
return packagePaths;
|
|
}
|
|
|
|
function findServerWorkspaceLinkMismatches(rootDir: string): WorkspaceLinkMismatch[] {
|
|
const serverPackageJsonPath = path.join(rootDir, "server", "package.json");
|
|
if (!existsSync(serverPackageJsonPath)) return [];
|
|
|
|
const serverPackageJson = readJsonFile(serverPackageJsonPath);
|
|
const dependencies = {
|
|
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
|
|
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
|
|
};
|
|
const workspacePackagePaths = discoverWorkspacePackagePaths(rootDir);
|
|
const mismatches: WorkspaceLinkMismatch[] = [];
|
|
|
|
for (const [packageName, version] of Object.entries(dependencies)) {
|
|
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
|
|
|
|
const expectedPath = workspacePackagePaths.get(packageName);
|
|
if (!expectedPath) continue;
|
|
const normalizedExpectedPath = existsSync(expectedPath) ? path.resolve(realpathSync(expectedPath)) : path.resolve(expectedPath);
|
|
|
|
const linkPath = path.join(rootDir, "server", "node_modules", ...packageName.split("/"));
|
|
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
|
if (actualPath === normalizedExpectedPath) continue;
|
|
|
|
mismatches.push({
|
|
packageName,
|
|
expectedPath: normalizedExpectedPath,
|
|
actualPath,
|
|
});
|
|
}
|
|
|
|
return mismatches;
|
|
}
|
|
|
|
export async function ensureServerWorkspaceLinksCurrent(
|
|
startCwd: string,
|
|
opts?: {
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
},
|
|
) {
|
|
const workspaceRoot = findWorkspaceRoot(startCwd);
|
|
if (!workspaceRoot) return;
|
|
if (!isLinkedGitWorktreeCheckout(workspaceRoot)) return;
|
|
|
|
const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
|
|
if (mismatches.length === 0) return;
|
|
|
|
if (opts?.onLog) {
|
|
await opts.onLog("stdout", "[runtime] detected stale workspace package links for server; relinking dependencies...\n");
|
|
for (const mismatch of mismatches) {
|
|
await opts.onLog(
|
|
"stdout",
|
|
`[runtime] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const mismatch of mismatches) {
|
|
const linkPath = path.join(workspaceRoot, "server", "node_modules", ...mismatch.packageName.split("/"));
|
|
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
|
await fs.rm(linkPath, { recursive: true, force: true });
|
|
await fs.symlink(mismatch.expectedPath, linkPath);
|
|
}
|
|
|
|
const remainingMismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
|
|
if (remainingMismatches.length === 0) return;
|
|
|
|
throw new Error(
|
|
`Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
|
for (const key of Object.keys(env)) {
|
|
if (key.startsWith("PAPERCLIP_")) {
|
|
delete env[key];
|
|
}
|
|
}
|
|
// These origin settings belong to the parent instance. Letting them leak into a
|
|
// managed worktree runtime can send auth cookies and OAuth callbacks to the wrong
|
|
// Paperclip instance. Runtime/service overrides are merged back after sanitizing.
|
|
delete env.BETTER_AUTH_URL;
|
|
delete env.BETTER_AUTH_BASE_URL;
|
|
delete env.DATABASE_URL;
|
|
delete env.npm_config_tailscale_auth;
|
|
delete env.npm_config_authenticated_private;
|
|
return env;
|
|
}
|
|
|
|
function stableRuntimeServiceId(input: {
|
|
adapterType: string;
|
|
runId: string;
|
|
scopeType: RuntimeServiceRef["scopeType"];
|
|
scopeId: string | null;
|
|
serviceName: string;
|
|
reportId: string | null;
|
|
providerRef: string | null;
|
|
reuseKey: string | null;
|
|
}) {
|
|
if (input.reportId) return input.reportId;
|
|
const digest = createHash("sha256")
|
|
.update(
|
|
stableStringify({
|
|
adapterType: input.adapterType,
|
|
runId: input.runId,
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
serviceName: input.serviceName,
|
|
providerRef: input.providerRef,
|
|
reuseKey: input.reuseKey,
|
|
}),
|
|
)
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
return `${input.adapterType}-${digest}`;
|
|
}
|
|
|
|
function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial<RuntimeServiceRef>): RuntimeServiceRef {
|
|
return {
|
|
id: record.id,
|
|
companyId: record.companyId,
|
|
projectId: record.projectId,
|
|
projectWorkspaceId: record.projectWorkspaceId,
|
|
executionWorkspaceId: record.executionWorkspaceId,
|
|
issueId: record.issueId,
|
|
serviceName: record.serviceName,
|
|
status: record.status,
|
|
lifecycle: record.lifecycle,
|
|
scopeType: record.scopeType,
|
|
scopeId: record.scopeId,
|
|
reuseKey: record.reuseKey,
|
|
command: record.command,
|
|
cwd: record.cwd,
|
|
port: record.port,
|
|
url: record.url,
|
|
provider: record.provider,
|
|
providerRef: record.providerRef,
|
|
ownerAgentId: record.ownerAgentId,
|
|
startedByRunId: record.startedByRunId,
|
|
lastUsedAt: record.lastUsedAt,
|
|
startedAt: record.startedAt,
|
|
stoppedAt: record.stoppedAt,
|
|
stopPolicy: record.stopPolicy,
|
|
exposure: record.exposure,
|
|
healthStatus: record.healthStatus,
|
|
reused: record.reused,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function sanitizeSlugPart(value: string | null | undefined, fallback: string): string {
|
|
const raw = (value ?? "").trim().toLowerCase();
|
|
const normalized = raw
|
|
.replace(/[^a-z0-9_-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^[-_]+|[-_]+$/g, "");
|
|
return normalized.length > 0 ? normalized : fallback;
|
|
}
|
|
|
|
function renderWorkspaceTemplate(template: string, input: {
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
projectId: string | null;
|
|
repoRef: string | null;
|
|
}) {
|
|
const issueIdentifier = input.issue?.identifier ?? input.issue?.id ?? "issue";
|
|
const slug = sanitizeSlugPart(input.issue?.title, sanitizeSlugPart(issueIdentifier, "issue"));
|
|
return renderTemplate(template, {
|
|
issue: {
|
|
id: input.issue?.id ?? "",
|
|
identifier: input.issue?.identifier ?? "",
|
|
title: input.issue?.title ?? "",
|
|
},
|
|
agent: {
|
|
id: input.agent.id ?? "",
|
|
name: input.agent.name,
|
|
},
|
|
project: {
|
|
id: input.projectId ?? "",
|
|
},
|
|
workspace: {
|
|
repoRef: input.repoRef ?? "",
|
|
},
|
|
slug,
|
|
});
|
|
}
|
|
|
|
function sanitizeBranchName(value: string): string {
|
|
return value
|
|
.trim()
|
|
.replace(/[^A-Za-z0-9._/-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^[-/.]+|[-/.]+$/g, "")
|
|
.slice(0, 120) || "paperclip-work";
|
|
}
|
|
|
|
function isAbsolutePath(value: string) {
|
|
return path.isAbsolute(value) || value.startsWith("~");
|
|
}
|
|
|
|
function resolveConfiguredPath(value: string, baseDir: string): string {
|
|
if (isAbsolutePath(value)) {
|
|
return resolveHomeAwarePath(value);
|
|
}
|
|
return path.resolve(baseDir, value);
|
|
}
|
|
|
|
function formatCommandForDisplay(command: string, args: string[]) {
|
|
return [command, ...args]
|
|
.map((part) => (/^[A-Za-z0-9_./:-]+$/.test(part) ? part : JSON.stringify(part)))
|
|
.join(" ");
|
|
}
|
|
|
|
function trimToLastBytes(value: string, limit: number) {
|
|
const byteLength = Buffer.byteLength(value, "utf8");
|
|
if (byteLength <= limit) return value;
|
|
return Buffer.from(value, "utf8").subarray(byteLength - limit).toString("utf8");
|
|
}
|
|
|
|
function createProcessOutputCapture(maxBytes: number): ProcessOutputAccumulator {
|
|
const limit = Math.max(1, Math.trunc(maxBytes));
|
|
let text = "";
|
|
let truncated = false;
|
|
let totalBytes = 0;
|
|
|
|
return {
|
|
append(chunk: string) {
|
|
if (!chunk) return;
|
|
totalBytes += Buffer.byteLength(chunk, "utf8");
|
|
|
|
const combined = text + chunk;
|
|
if (Buffer.byteLength(combined, "utf8") <= limit) {
|
|
text = combined;
|
|
return;
|
|
}
|
|
|
|
text = trimToLastBytes(combined, limit);
|
|
truncated = true;
|
|
},
|
|
finish(): ProcessOutputCapture {
|
|
if (!truncated) {
|
|
return {
|
|
text,
|
|
truncated: false,
|
|
totalBytes,
|
|
};
|
|
}
|
|
return {
|
|
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${text}`,
|
|
truncated: true,
|
|
totalBytes,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
async function executeProcess(input: {
|
|
command: string;
|
|
args: string[];
|
|
cwd: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
maxStdoutBytes?: number;
|
|
maxStderrBytes?: number;
|
|
}): Promise<{
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number | null;
|
|
stdoutTruncated: boolean;
|
|
stderrTruncated: boolean;
|
|
stdoutBytes: number;
|
|
stderrBytes: number;
|
|
}> {
|
|
const proc = await new Promise<{
|
|
stdout: ProcessOutputAccumulator;
|
|
stderr: ProcessOutputAccumulator;
|
|
code: number | null;
|
|
}>((resolve, reject) => {
|
|
const child = spawn(input.command, input.args, {
|
|
cwd: input.cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: input.env ?? process.env,
|
|
});
|
|
const stdout = createProcessOutputCapture(input.maxStdoutBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
|
|
const stderr = createProcessOutputCapture(input.maxStderrBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout.append(String(chunk));
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr.append(String(chunk));
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) => resolve({ stdout, stderr, code }));
|
|
});
|
|
const stdout = proc.stdout.finish();
|
|
const stderr = proc.stderr.finish();
|
|
return {
|
|
stdout: stdout.text,
|
|
stderr: stderr.text,
|
|
code: proc.code,
|
|
stdoutTruncated: stdout.truncated,
|
|
stderrTruncated: stderr.truncated,
|
|
stdoutBytes: stdout.totalBytes,
|
|
stderrBytes: stderr.totalBytes,
|
|
};
|
|
}
|
|
|
|
async function runGit(args: string[], cwd: string, opts?: {
|
|
env?: NodeJS.ProcessEnv;
|
|
observe?: (result: Awaited<ReturnType<typeof executeProcess>>) => void;
|
|
}): Promise<string> {
|
|
const proc = await executeProcess({
|
|
command: "git",
|
|
args,
|
|
cwd,
|
|
env: opts?.env,
|
|
});
|
|
opts?.observe?.(proc);
|
|
if (proc.code !== 0) {
|
|
throw new Error(proc.stderr.trim() || proc.stdout.trim() || `git ${args.join(" ")} failed`);
|
|
}
|
|
return proc.stdout.trim();
|
|
}
|
|
|
|
async function runExpensiveGitStatus(input: {
|
|
args: readonly string[];
|
|
cwd: string;
|
|
operation: string;
|
|
fairnessKeys?: readonly string[];
|
|
}): Promise<string> {
|
|
const result = await workspaceGitOperationScheduler.run({
|
|
workspacePath: input.cwd,
|
|
args: input.args,
|
|
operation: input.operation,
|
|
fairnessKeys: input.fairnessKeys,
|
|
cacheTtlMs: 0,
|
|
});
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function formatShortSha(value: string | null | undefined) {
|
|
return value ? value.slice(0, 12) : "unknown";
|
|
}
|
|
|
|
function gitErrorIncludes(error: unknown, needle: string) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return message.toLowerCase().includes(needle.toLowerCase());
|
|
}
|
|
|
|
function parseRemoteTrackingRef(ref: string): { remote: string; branch: string } | null {
|
|
const trimmed = ref.trim();
|
|
const refsRemotesPrefix = "refs/remotes/";
|
|
const normalized = trimmed.startsWith(refsRemotesPrefix)
|
|
? trimmed.slice(refsRemotesPrefix.length)
|
|
: trimmed;
|
|
const slashIndex = normalized.indexOf("/");
|
|
if (slashIndex <= 0 || slashIndex === normalized.length - 1) return null;
|
|
const remote = normalized.slice(0, slashIndex);
|
|
const branch = normalized.slice(slashIndex + 1);
|
|
if (!/^[A-Za-z0-9._-]+$/.test(remote)) return null;
|
|
return { remote, branch };
|
|
}
|
|
|
|
export async function refreshRemoteTrackingBaseRef(
|
|
repoRoot: string,
|
|
baseRef: string,
|
|
resolveGitAuth?: GitRemoteAuthProvider | null,
|
|
): Promise<string[]> {
|
|
return (await refreshRemoteTrackingBaseRefWithDiagnostic(repoRoot, baseRef, resolveGitAuth)).warnings;
|
|
}
|
|
|
|
async function refreshRemoteTrackingBaseRefWithDiagnostic(
|
|
repoRoot: string,
|
|
baseRef: string,
|
|
resolveGitAuth?: GitRemoteAuthProvider | null,
|
|
): Promise<{ warnings: string[]; diagnostic: WorkspaceBaseRefDiagnostic }> {
|
|
const diagnostic: WorkspaceBaseRefDiagnostic = {
|
|
schemaVersion: 1, remoteLookup: "not_attempted", authLookup: "not_requested", fetch: "not_attempted",
|
|
};
|
|
const remoteTracking = parseRemoteTrackingRef(baseRef);
|
|
if (!remoteTracking) return { warnings: [], diagnostic };
|
|
|
|
const remoteUrl = await runGit(["remote", "get-url", remoteTracking.remote], repoRoot)
|
|
.then((value) => {
|
|
diagnostic.remoteLookup = value.trim() ? "resolved" : "empty";
|
|
return value.trim() || null;
|
|
})
|
|
.catch(() => { diagnostic.remoteLookup = "failed"; return null; });
|
|
if (!remoteUrl) return { warnings: [], diagnostic };
|
|
|
|
const auth = resolveGitAuth ? await resolveGitAuth(remoteUrl).then((value) => {
|
|
diagnostic.authLookup = value ? "resolved" : "unavailable";
|
|
return value;
|
|
}).catch(() => { diagnostic.authLookup = "failed"; return null; }) : null;
|
|
try {
|
|
await runGit([
|
|
...(auth?.configArgs ?? []),
|
|
"fetch",
|
|
"--prune",
|
|
remoteTracking.remote,
|
|
`+refs/heads/${remoteTracking.branch}:refs/remotes/${remoteTracking.remote}/${remoteTracking.branch}`,
|
|
], repoRoot, {
|
|
...(auth ? { env: { ...process.env, ...auth.env } } : {}),
|
|
observe: (proc) => {
|
|
diagnostic.fetch = proc.code === 0 ? "succeeded" : "failed";
|
|
if (proc.code !== null) diagnostic.fetchExitCode = proc.code;
|
|
if (proc.code === 0) return;
|
|
diagnostic.fetchFailureKind = "unknown";
|
|
// Diagnostic parsing must not add unbounded work to an already failed
|
|
// fetch. Keep the existing captured warning/output limits unchanged.
|
|
if (proc.stdoutTruncated || proc.stderrTruncated || proc.stderrBytes > 8192 || proc.stdoutBytes > 8192) return;
|
|
// Reuse only the closed transport diagnostic parser. This is not a
|
|
// clone failure marker and never changes reporting or ref validation.
|
|
const failure = Object.assign(new Error(), { code: proc.code, stderr: proc.stderr });
|
|
diagnostic.fetchFailureKind = classifyGitCloneFailure(remoteUrl, failure)?.reason ?? "unknown";
|
|
if (proc.code === 128 && !proc.stdout.trim() &&
|
|
proc.stderr.trim() === `fatal: couldn't find remote ref refs/heads/${remoteTracking.branch}`) {
|
|
diagnostic.fetchFailureKind = "remote_ref_not_found";
|
|
}
|
|
},
|
|
});
|
|
return { warnings: [], diagnostic };
|
|
} catch (error) {
|
|
diagnostic.fetch = "failed";
|
|
diagnostic.fetchFailureKind ??= "unknown";
|
|
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
// Keep the existing warning contract; new diagnostics never retain this text.
|
|
const message = rawMessage
|
|
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1***@")
|
|
.replace(/([a-z][a-z0-9+.-]*:\/\/[^\s"'?]*)\?[^\s"']*/gi, "$1?***");
|
|
const authNote = auth
|
|
? ` The fetch used ${auth.secretName ? `the ${auth.secretName} company-secret GitHub credential` : "the server-environment GitHub credential"}.`
|
|
: "";
|
|
return { warnings: [`Could not refresh base ref ${baseRef} before preparing the execution workspace: ${message}${authNote}`], diagnostic };
|
|
}
|
|
}
|
|
|
|
async function resolveBaseRefSha(repoRoot: string, baseRef: string, diagnostic?: WorkspaceBaseRefDiagnostic): Promise<string | null> {
|
|
return await runGit(["rev-parse", "--verify", `${baseRef}^{commit}`], repoRoot, diagnostic ? {
|
|
observe: (proc) => {
|
|
diagnostic.refResolution = proc.code === 0 ? "succeeded" : "failed";
|
|
if (proc.code !== null) diagnostic.refExitCode = proc.code;
|
|
},
|
|
} : undefined).catch(() => {
|
|
if (diagnostic && !diagnostic.refResolution) diagnostic.refResolution = "spawn_failed";
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function readRecordedBaseRefSha(metadata: Record<string, unknown> | null | undefined): string | null {
|
|
const snapshot = parseObject(metadata?.baseRefSnapshot);
|
|
const resolvedSha = snapshot.resolvedSha;
|
|
return typeof resolvedSha === "string" && resolvedSha.trim().length > 0 ? resolvedSha.trim() : null;
|
|
}
|
|
|
|
export async function inspectExecutionWorkspaceBaseDrift(input: {
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
branchName: string | null;
|
|
baseRef: string | null;
|
|
recordedBaseRefSha?: string | null;
|
|
skipRefresh?: boolean;
|
|
resolveGitAuth?: GitRemoteAuthProvider | null;
|
|
}): Promise<{
|
|
warnings: string[];
|
|
currentBaseRefSha: string | null;
|
|
branchBaseRefSha: string | null;
|
|
}> {
|
|
const baseRef = input.baseRef?.trim();
|
|
if (!baseRef) {
|
|
return { warnings: [], currentBaseRefSha: null, branchBaseRefSha: null };
|
|
}
|
|
|
|
const warnings = input.skipRefresh
|
|
? []
|
|
: await refreshRemoteTrackingBaseRef(input.repoRoot, baseRef, input.resolveGitAuth);
|
|
const currentBaseRefSha = await resolveBaseRefSha(input.repoRoot, baseRef);
|
|
if (!currentBaseRefSha) {
|
|
warnings.push(`Could not resolve base ref ${baseRef} while checking execution workspace freshness.`);
|
|
return { warnings, currentBaseRefSha: null, branchBaseRefSha: null };
|
|
}
|
|
|
|
const branchBaseRefSha = await runGit(["merge-base", "HEAD", baseRef], input.worktreePath).catch(() => null);
|
|
if (!branchBaseRefSha) {
|
|
warnings.push(`Could not compare execution workspace ${input.branchName ?? "branch"} against base ref ${baseRef}.`);
|
|
return { warnings, currentBaseRefSha, branchBaseRefSha: null };
|
|
}
|
|
|
|
if (branchBaseRefSha !== currentBaseRefSha) {
|
|
const behindCountRaw = await runGit(["rev-list", "--count", `HEAD..${baseRef}`], input.worktreePath).catch(() => "");
|
|
const behindCount = Number.parseInt(behindCountRaw, 10);
|
|
const behindText = Number.isFinite(behindCount) && behindCount > 0
|
|
? `${behindCount} commit${behindCount === 1 ? "" : "s"}`
|
|
: "newer commits";
|
|
const recordedText = input.recordedBaseRefSha
|
|
? `recorded base ${formatShortSha(input.recordedBaseRefSha)}`
|
|
: `merge-base ${formatShortSha(branchBaseRefSha)}`;
|
|
warnings.push(
|
|
`Execution workspace branch ${input.branchName ? `"${input.branchName}"` : "HEAD"} is behind ${baseRef} by ${behindText}: ${recordedText}, current base ${formatShortSha(currentBaseRefSha)}. Refresh or rebase the workspace before relying on recent base-branch fixes.`,
|
|
);
|
|
}
|
|
|
|
return { warnings, currentBaseRefSha, branchBaseRefSha };
|
|
}
|
|
|
|
async function localBranchExists(repoRoot: string, branch: string): Promise<boolean> {
|
|
return runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
}
|
|
|
|
async function remoteExists(repoRoot: string, remote: string): Promise<boolean> {
|
|
return runGit(["remote", "get-url", remote], repoRoot)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
}
|
|
|
|
const GIT_WORKTREE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence";
|
|
|
|
type GitWorktreeCleanliness = SharedGitWorktreeBranchIncoherenceEvidence["cleanliness"];
|
|
|
|
type GitWorktreeBranchIncoherenceEvidence = SharedGitWorktreeBranchIncoherenceEvidence;
|
|
|
|
type GitWorktreeBranchContention = NonNullable<GitWorktreeBranchIncoherenceEvidence["contention"]>;
|
|
|
|
type GitWorktreeBranchCoherenceResult = {
|
|
branchName: string | null;
|
|
reconciledForward: boolean;
|
|
pendingForwardBranchReconcile?: PendingForwardBranchReconcile | null;
|
|
dirtyQuarantineRepair?: DirtyQuarantineRepairResult | null;
|
|
warnings: string[];
|
|
};
|
|
|
|
type DirtyQuarantineRepairResult = {
|
|
rescueBranch: string;
|
|
rescueCommitSha: string;
|
|
fileCount: number;
|
|
clearedInProgressOperation: GitWorktreeInProgressOperation | null;
|
|
sourceAuditCommentId: string | null;
|
|
claimantAuditCommentId: string | null;
|
|
};
|
|
|
|
export type PendingForwardBranchReconcile = {
|
|
recordedBranchName: string;
|
|
adoptedBranchName: string;
|
|
prePersistenceFingerprint: string;
|
|
reason: string;
|
|
};
|
|
|
|
function formatBranchForMessage(branch: string | null | undefined) {
|
|
return branch && branch.length > 0 ? branch : "<detached>";
|
|
}
|
|
|
|
const GIT_IN_PROGRESS_OPERATION_MARKERS: ReadonlyArray<{
|
|
operation: GitWorktreeInProgressOperation;
|
|
marker: string;
|
|
}> = [
|
|
{ operation: "rebase", marker: "rebase-merge" },
|
|
{ operation: "rebase", marker: "rebase-apply" },
|
|
{ operation: "merge", marker: "MERGE_HEAD" },
|
|
{ operation: "cherry_pick", marker: "CHERRY_PICK_HEAD" },
|
|
{ operation: "revert", marker: "REVERT_HEAD" },
|
|
{ operation: "bisect", marker: "BISECT_LOG" },
|
|
];
|
|
|
|
const GIT_IN_PROGRESS_OPERATION_LABELS: Record<GitWorktreeInProgressOperation, string> = {
|
|
rebase: "rebase",
|
|
merge: "merge",
|
|
cherry_pick: "cherry-pick",
|
|
revert: "revert",
|
|
bisect: "bisect",
|
|
};
|
|
|
|
// `--quit` clears the interrupted operation's state directory without touching
|
|
// the working tree or moving HEAD, unlike `--abort` which resets both.
|
|
const GIT_IN_PROGRESS_OPERATION_QUIT_ARGS: Record<GitWorktreeInProgressOperation, string[]> = {
|
|
rebase: ["rebase", "--quit"],
|
|
merge: ["merge", "--quit"],
|
|
cherry_pick: ["cherry-pick", "--quit"],
|
|
revert: ["revert", "--quit"],
|
|
bisect: ["bisect", "reset", "HEAD"],
|
|
};
|
|
|
|
async function detectGitWorktreeInProgressOperation(
|
|
worktreePath: string,
|
|
): Promise<GitWorktreeInProgressOperation | null> {
|
|
for (const { operation, marker } of GIT_IN_PROGRESS_OPERATION_MARKERS) {
|
|
const markerPath = await runGit(["rev-parse", "--git-path", marker], worktreePath).catch(() => null);
|
|
if (!markerPath) continue;
|
|
if (existsSync(path.resolve(worktreePath, markerPath))) return operation;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const DIRTY_PATH_SAMPLE_LIMIT = 5;
|
|
|
|
function parseGitPorcelainPath(line: string) {
|
|
const raw = line.trimEnd();
|
|
if (raw.trim().length <= 3) return raw.trim();
|
|
if (raw[1] === " " && raw[2] !== " ") return raw.slice(2).trim();
|
|
return raw.slice(3).trim();
|
|
}
|
|
|
|
function sampleDirtyStatusPaths(statusLines: string[] | null) {
|
|
return (statusLines ?? [])
|
|
.map(parseGitPorcelainPath)
|
|
.filter((value) => value.length > 0)
|
|
.slice(0, DIRTY_PATH_SAMPLE_LIMIT);
|
|
}
|
|
|
|
function formatUtcBranchTimestamp(date = new Date()) {
|
|
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
}
|
|
|
|
function buildDirtyQuarantineRescueBranch(sourceIssue: ExecutionWorkspaceIssueRef | null) {
|
|
const issueComponent = sanitizeBranchName(sourceIssue?.identifier ?? sourceIssue?.id ?? "issue");
|
|
return sanitizeBranchName(`paperclip/rescue/${issueComponent}/${formatUtcBranchTimestamp()}`);
|
|
}
|
|
|
|
function formatIssueReference(issueId: string | null | undefined, identifier: string | null | undefined) {
|
|
if (!identifier) return issueId ? `\`${issueId}\`` : "`unknown`";
|
|
const match = identifier.match(/^([A-Z]+)-\d+$/);
|
|
if (!match) return `\`${identifier}\``;
|
|
return `[${identifier}](/${match[1]}/issues/${identifier})`;
|
|
}
|
|
|
|
async function readIssueCompanyId(db: Db, issueId: string | null | undefined): Promise<string | null> {
|
|
if (!issueId) return null;
|
|
return db
|
|
.select({ companyId: issues.companyId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0]?.companyId ?? null);
|
|
}
|
|
|
|
async function findGitWorktreeBranchContention(input: {
|
|
db: Db | null | undefined;
|
|
sourceIssue: ExecutionWorkspaceIssueRef | null;
|
|
executionWorkspaceId: string | null;
|
|
worktreePath: string;
|
|
actualBranchName: string | null;
|
|
}): Promise<GitWorktreeBranchContention | null> {
|
|
if (!input.db) return null;
|
|
const companyId = await readIssueCompanyId(input.db, input.sourceIssue?.id);
|
|
if (!companyId) return null;
|
|
return executionWorkspaceService(input.db).findGitWorktreeContention({
|
|
companyId,
|
|
worktreePath: input.worktreePath,
|
|
liveBranchName: input.actualBranchName,
|
|
excludingExecutionWorkspaceId: input.executionWorkspaceId,
|
|
});
|
|
}
|
|
|
|
function executionWorkspaceUsesInheritedProjectRuntimeServices(
|
|
row: typeof executionWorkspaces.$inferSelect,
|
|
) {
|
|
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
|
|
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
|
|
}
|
|
|
|
async function findActiveRuntimeServiceBlockingDirtyQuarantine(input: {
|
|
db: Db;
|
|
workspace: typeof executionWorkspaces.$inferSelect;
|
|
}) {
|
|
const inheritedProjectWorkspaceId = executionWorkspaceUsesInheritedProjectRuntimeServices(input.workspace)
|
|
? input.workspace.projectWorkspaceId
|
|
: null;
|
|
const serviceScopeCondition = inheritedProjectWorkspaceId
|
|
? and(
|
|
eq(workspaceRuntimeServices.companyId, input.workspace.companyId),
|
|
eq(workspaceRuntimeServices.projectWorkspaceId, inheritedProjectWorkspaceId),
|
|
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
|
)
|
|
: and(
|
|
eq(workspaceRuntimeServices.companyId, input.workspace.companyId),
|
|
eq(workspaceRuntimeServices.executionWorkspaceId, input.workspace.id),
|
|
);
|
|
|
|
const [service] = await input.db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
serviceName: workspaceRuntimeServices.serviceName,
|
|
status: workspaceRuntimeServices.status,
|
|
scopeType: workspaceRuntimeServices.scopeType,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(and(serviceScopeCondition, ne(workspaceRuntimeServices.status, "stopped")))
|
|
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt))
|
|
.limit(1);
|
|
return service ?? null;
|
|
}
|
|
|
|
async function assertDirtyQuarantineRuntimeServicesStopped(input: {
|
|
db: Db;
|
|
executionWorkspaceId: string | null;
|
|
evidence: GitWorktreeBranchIncoherenceEvidence;
|
|
}) {
|
|
if (!input.executionWorkspaceId) {
|
|
input.evidence.safeRepair.eligible = false;
|
|
input.evidence.safeRepair.reason = "dirty quarantine repair requires an execution workspace id for runtime-service checks";
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
|
|
const [workspace] = await input.db
|
|
.select()
|
|
.from(executionWorkspaces)
|
|
.where(eq(executionWorkspaces.id, input.executionWorkspaceId));
|
|
if (!workspace) {
|
|
input.evidence.safeRepair.eligible = false;
|
|
input.evidence.safeRepair.reason = "dirty quarantine repair requires a persisted execution workspace for runtime-service checks";
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
|
|
const activeService = await findActiveRuntimeServiceBlockingDirtyQuarantine({
|
|
db: input.db,
|
|
workspace,
|
|
});
|
|
if (!activeService) return;
|
|
|
|
input.evidence.safeRepair.eligible = false;
|
|
input.evidence.safeRepair.reason =
|
|
`dirty quarantine repair requires runtime service "${activeService.serviceName}" (${activeService.id}) to be stopped; current status is ${activeService.status}`;
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
|
|
async function assertGitIndexIsUnlocked(worktreePath: string) {
|
|
const indexLockPath = await runGit(["rev-parse", "--git-path", "index.lock"], worktreePath)
|
|
.catch(() => null);
|
|
if (indexLockPath && existsSync(indexLockPath)) {
|
|
throw new Error(`git index lock exists at ${indexLockPath}`);
|
|
}
|
|
}
|
|
|
|
function fingerprintWorkspaceBranchIncoherence(input: {
|
|
sourceIssueId: string | null;
|
|
executionWorkspaceId: string | null;
|
|
worktreePath: string;
|
|
expectedBranch: string;
|
|
actualBranch: string | null;
|
|
cleanliness: GitWorktreeCleanliness;
|
|
expectedHeadSha: string | null;
|
|
actualHeadSha: string | null;
|
|
}) {
|
|
const digest = createHash("sha256")
|
|
.update(stableStringify({
|
|
version: 1,
|
|
reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON,
|
|
sourceIssueId: input.sourceIssueId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
worktreePath: path.resolve(input.worktreePath),
|
|
expectedBranch: input.expectedBranch,
|
|
actualBranch: input.actualBranch,
|
|
cleanliness: input.cleanliness,
|
|
expectedHeadSha: input.expectedHeadSha,
|
|
actualHeadSha: input.actualHeadSha,
|
|
}))
|
|
.digest("hex");
|
|
return `workspace_incoherence:v1:sha256:${digest}`;
|
|
}
|
|
|
|
async function getGitWorktreeBranchAncestryVerdict(input: {
|
|
repoRoot: string;
|
|
expectedHeadSha: string | null;
|
|
actualHeadSha: string | null;
|
|
}): Promise<GitWorktreeBranchAncestryVerdict> {
|
|
if (!input.expectedHeadSha || !input.actualHeadSha) return "unknown";
|
|
|
|
const proc = await executeProcess({
|
|
command: "git",
|
|
args: ["merge-base", "--is-ancestor", input.expectedHeadSha, input.actualHeadSha],
|
|
cwd: input.repoRoot,
|
|
}).catch(() => null);
|
|
if (!proc) return "unknown";
|
|
if (proc.code === 0) return "ancestor";
|
|
if (proc.code === 1) return "diverged";
|
|
return "unknown";
|
|
}
|
|
|
|
function explainGitWorktreeBranchIncoherence(input: {
|
|
expectedBranchName: string;
|
|
actualBranchName: string | null;
|
|
expectedHeadSha: string | null;
|
|
actualHeadSha: string | null;
|
|
sameHead: boolean;
|
|
ancestryVerdict: GitWorktreeBranchAncestryVerdict;
|
|
}) {
|
|
const actualBranch = formatBranchForMessage(input.actualBranchName);
|
|
if (!input.expectedHeadSha || !input.actualHeadSha) {
|
|
return `Paperclip could not determine branch ancestry because the recorded branch "${input.expectedBranchName}" or checked-out branch "${actualBranch}" is missing a resolvable HEAD commit.`;
|
|
}
|
|
if (input.sameHead) {
|
|
return `The recorded branch "${input.expectedBranchName}" and checked-out branch "${actualBranch}" resolve to the same commit, so the mismatch is branch metadata rather than commit divergence.`;
|
|
}
|
|
if (input.ancestryVerdict === "ancestor") {
|
|
return `The recorded branch "${input.expectedBranchName}" is an ancestor of the checked-out branch "${actualBranch}", so the checked-out branch is forward of the recorded branch.`;
|
|
}
|
|
if (input.ancestryVerdict === "diverged") {
|
|
return `The recorded branch "${input.expectedBranchName}" is not an ancestor of the checked-out branch "${actualBranch}", so Paperclip cannot prove a forward-only reconciliation.`;
|
|
}
|
|
return `Paperclip could not determine whether the checked-out branch "${actualBranch}" is forward of the recorded branch "${input.expectedBranchName}".`;
|
|
}
|
|
|
|
async function inspectGitWorktreeBranchIncoherence(input: {
|
|
db?: Db | null;
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
expectedBranchName: string;
|
|
actualBranchName: string | null;
|
|
sourceIssue: ExecutionWorkspaceIssueRef | null;
|
|
executionWorkspaceId?: string | null;
|
|
}): Promise<GitWorktreeBranchIncoherenceEvidence> {
|
|
const status = await runExpensiveGitStatus({
|
|
args: ["status", "--porcelain", "--untracked-files=all"],
|
|
cwd: input.worktreePath,
|
|
operation: "workspace_runtime.branch_incoherence_status",
|
|
fairnessKeys: [
|
|
...(input.executionWorkspaceId ? [`workspace:${input.executionWorkspaceId}`] : []),
|
|
...(input.sourceIssue?.id ? [`issue:${input.sourceIssue.id}`] : []),
|
|
],
|
|
}).catch(() => null);
|
|
const statusLines = status === null
|
|
? null
|
|
: status.split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
|
|
const dirtyPathSample = sampleDirtyStatusPaths(statusLines);
|
|
const cleanliness: GitWorktreeCleanliness =
|
|
status === null ? "unknown" : status.trim().length > 0 ? "dirty" : "clean";
|
|
const inProgressOperation = await detectGitWorktreeInProgressOperation(input.worktreePath);
|
|
const expectedHeadSha = await runGit(
|
|
["rev-parse", "--verify", `refs/heads/${input.expectedBranchName}^{commit}`],
|
|
input.repoRoot,
|
|
).catch(() => null);
|
|
const actualHeadSha = await runGit(["rev-parse", "HEAD"], input.worktreePath).catch(() => null);
|
|
const actualBranchExists = input.actualBranchName
|
|
? await localBranchExists(input.repoRoot, input.actualBranchName)
|
|
: null;
|
|
const registered = await findRegisteredGitWorktreeByPath(input.repoRoot, input.worktreePath);
|
|
const actualBranchRef = input.actualBranchName ? `refs/heads/${input.actualBranchName}` : null;
|
|
const registeredBranchRef = registered?.branch ?? null;
|
|
const registeredBranchMatchesHead = Boolean(registered && registeredBranchRef === actualBranchRef);
|
|
const sameHead = Boolean(expectedHeadSha && actualHeadSha && expectedHeadSha === actualHeadSha);
|
|
const expectedBranchExists = Boolean(expectedHeadSha);
|
|
const ancestryVerdict = await getGitWorktreeBranchAncestryVerdict({
|
|
repoRoot: input.repoRoot,
|
|
expectedHeadSha,
|
|
actualHeadSha,
|
|
});
|
|
const basePlainLanguageReason = explainGitWorktreeBranchIncoherence({
|
|
expectedBranchName: input.expectedBranchName,
|
|
actualBranchName: input.actualBranchName,
|
|
expectedHeadSha,
|
|
actualHeadSha,
|
|
sameHead,
|
|
ancestryVerdict,
|
|
});
|
|
const plainLanguageReason = inProgressOperation
|
|
? `${basePlainLanguageReason} An interrupted git ${GIT_IN_PROGRESS_OPERATION_LABELS[inProgressOperation]} is still in progress in this worktree.`
|
|
: basePlainLanguageReason;
|
|
const canCheckoutRecordedBranch =
|
|
cleanliness === "clean" && expectedBranchExists && sameHead && registeredBranchMatchesHead;
|
|
const canAdoptForwardActualBranch =
|
|
cleanliness === "clean" &&
|
|
expectedBranchExists &&
|
|
actualBranchExists === true &&
|
|
ancestryVerdict === "ancestor" &&
|
|
!sameHead &&
|
|
registeredBranchMatchesHead;
|
|
const canAttachRecordedBranchToDetachedHead =
|
|
cleanliness === "clean" &&
|
|
expectedBranchExists &&
|
|
input.actualBranchName === null &&
|
|
ancestryVerdict === "ancestor" &&
|
|
!sameHead &&
|
|
registeredBranchMatchesHead;
|
|
const eligible =
|
|
canCheckoutRecordedBranch || canAdoptForwardActualBranch || canAttachRecordedBranchToDetachedHead;
|
|
const safeRepairReason = eligible
|
|
? canCheckoutRecordedBranch
|
|
? "clean worktree and expected branch points at the current HEAD"
|
|
: canAdoptForwardActualBranch
|
|
? "clean worktree and checked-out branch is forward of the recorded branch"
|
|
: "clean detached worktree HEAD is forward of the recorded branch"
|
|
: cleanliness !== "clean"
|
|
? inProgressOperation
|
|
? `worktree is not clean and a git ${GIT_IN_PROGRESS_OPERATION_LABELS[inProgressOperation]} is in progress`
|
|
: "worktree is not clean"
|
|
: !registered
|
|
? "worktree path is not registered"
|
|
: !registeredBranchMatchesHead
|
|
? "registered worktree branch does not match HEAD"
|
|
: !expectedBranchExists
|
|
? "expected branch does not exist"
|
|
: !sameHead
|
|
? "expected branch and current HEAD differ"
|
|
: "safe repair could not be proven";
|
|
const fingerprint = fingerprintWorkspaceBranchIncoherence({
|
|
sourceIssueId: input.sourceIssue?.id ?? null,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranch: input.expectedBranchName,
|
|
actualBranch: input.actualBranchName,
|
|
cleanliness,
|
|
expectedHeadSha,
|
|
actualHeadSha,
|
|
});
|
|
const contention = await findGitWorktreeBranchContention({
|
|
db: input.db ?? null,
|
|
sourceIssue: input.sourceIssue,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
worktreePath: input.worktreePath,
|
|
actualBranchName: input.actualBranchName,
|
|
});
|
|
|
|
return {
|
|
reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON,
|
|
fingerprint,
|
|
sourceIssueId: input.sourceIssue?.id ?? null,
|
|
sourceIdentifier: input.sourceIssue?.identifier ?? null,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
worktreePath: path.resolve(input.worktreePath),
|
|
repoRoot: path.resolve(input.repoRoot),
|
|
expectedBranch: input.expectedBranchName,
|
|
actualBranch: input.actualBranchName,
|
|
cleanliness,
|
|
inProgressOperation,
|
|
statusEntryCount: statusLines?.length ?? null,
|
|
dirtyPathSample,
|
|
contention,
|
|
provenance: {
|
|
expectedBranchRef: `refs/heads/${input.expectedBranchName}`,
|
|
actualBranchRef,
|
|
registeredBranchRef,
|
|
registeredPathFound: Boolean(registered),
|
|
registeredBranchMatchesHead,
|
|
expectedBranchExists,
|
|
actualBranchExists,
|
|
expectedHeadSha,
|
|
actualHeadSha,
|
|
sameHead,
|
|
ancestryVerdict,
|
|
plainLanguageReason,
|
|
},
|
|
safeRepair: {
|
|
eligible,
|
|
attempted: false,
|
|
succeeded: false,
|
|
reason: safeRepairReason,
|
|
},
|
|
};
|
|
}
|
|
|
|
function branchIncoherenceValidationFailure(evidence: GitWorktreeBranchIncoherenceEvidence) {
|
|
return new WorkspaceRuntimeValidationFailure(
|
|
`Execution workspace git worktree expected branch "${evidence.expectedBranch}" but found "${formatBranchForMessage(evidence.actualBranch)}" at "${evidence.worktreePath}". Safe repair ${evidence.safeRepair.succeeded ? "succeeded" : "was not completed"}: ${evidence.safeRepair.reason}.`,
|
|
{
|
|
workspaceValidation: evidence,
|
|
},
|
|
);
|
|
}
|
|
|
|
function formatDirtyQuarantineContentionRefusal(contention: GitWorktreeBranchContention) {
|
|
const activeRunText = contention.activeRun
|
|
? ` with active run ${contention.activeRun.id}`
|
|
: " with no active run";
|
|
return `dirty quarantine repair refused because workspace ${contention.claimedByWorkspaceId} already claims the live branch${activeRunText}`;
|
|
}
|
|
|
|
function formatDirtyQuarantineFailure(error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (
|
|
gitErrorIncludes(error, "index.lock") ||
|
|
gitErrorIncludes(error, "index lock") ||
|
|
gitErrorIncludes(error, "another git process") ||
|
|
gitErrorIncludes(error, "Unable to create")
|
|
) {
|
|
return `dirty quarantine repair aborted because git reported index contention: ${message}`;
|
|
}
|
|
return `dirty quarantine repair failed: ${message}`;
|
|
}
|
|
|
|
function formatDirtyQuarantineAuditComment(input: {
|
|
evidence: GitWorktreeBranchIncoherenceEvidence;
|
|
rescueBranch: string;
|
|
rescueCommitSha: string;
|
|
fileCount: number;
|
|
sourceIssue: ExecutionWorkspaceIssueRef | null;
|
|
claimant: GitWorktreeBranchContention | null;
|
|
}) {
|
|
const dirtySample = input.evidence.dirtyPathSample.length > 0
|
|
? input.evidence.dirtyPathSample.map((entry) => `\`${entry}\``).join(", ")
|
|
: "`none captured`";
|
|
return [
|
|
"Execution workspace dirty worktree quarantined before restore.",
|
|
"",
|
|
`- Source issue: ${formatIssueReference(input.evidence.sourceIssueId, input.evidence.sourceIdentifier ?? input.sourceIssue?.identifier ?? null)}`,
|
|
`- Workspace: \`${input.evidence.executionWorkspaceId ?? "unpersisted"}\``,
|
|
`- Worktree: \`${input.evidence.worktreePath}\``,
|
|
`- Recorded branch: \`${input.evidence.expectedBranch}\``,
|
|
`- Live branch: \`${formatBranchForMessage(input.evidence.actualBranch)}\``,
|
|
`- Rescue branch: \`${input.rescueBranch}\``,
|
|
`- Rescue commit: \`${input.rescueCommitSha}\``,
|
|
`- Dirty file count: \`${input.fileCount}\``,
|
|
`- Dirty path sample: ${dirtySample}`,
|
|
...(input.evidence.inProgressOperation
|
|
? [`- Interrupted operation: \`git ${GIT_IN_PROGRESS_OPERATION_LABELS[input.evidence.inProgressOperation]}\` (state cleared after rescue; resolution preserved on the rescue branch)`]
|
|
: []),
|
|
`- Fingerprint: \`${input.evidence.fingerprint}\``,
|
|
input.claimant
|
|
? `- Claimant: workspace \`${input.claimant.claimedByWorkspaceId}\` on issue ${formatIssueReference(input.claimant.claimedByIssueId, input.claimant.claimedByIssueIdentifier)}${input.claimant.activeRun ? ` with active run \`${input.claimant.activeRun.id}\`` : " with no active run"}`
|
|
: "- Claimant: none",
|
|
].join("\n");
|
|
}
|
|
|
|
async function writeDirtyQuarantineAuditComments(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
evidence: GitWorktreeBranchIncoherenceEvidence;
|
|
sourceIssue: ExecutionWorkspaceIssueRef | null;
|
|
rescueBranch: string;
|
|
rescueCommitSha: string;
|
|
fileCount: number;
|
|
heartbeatRunId: string | null;
|
|
}): Promise<{ sourceAuditCommentId: string | null; claimantAuditCommentId: string | null }> {
|
|
const body = formatDirtyQuarantineAuditComment({
|
|
evidence: input.evidence,
|
|
rescueBranch: input.rescueBranch,
|
|
rescueCommitSha: input.rescueCommitSha,
|
|
fileCount: input.fileCount,
|
|
sourceIssue: input.sourceIssue,
|
|
claimant: input.evidence.contention,
|
|
});
|
|
let sourceAuditCommentId: string | null = null;
|
|
let claimantAuditCommentId: string | null = null;
|
|
const now = new Date();
|
|
if (input.evidence.sourceIssueId) {
|
|
const [sourceComment] = await input.db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId: input.companyId,
|
|
issueId: input.evidence.sourceIssueId,
|
|
authorAgentId: null,
|
|
authorUserId: null,
|
|
authorType: "system",
|
|
createdByRunId: input.heartbeatRunId,
|
|
body,
|
|
})
|
|
.returning({ id: issueComments.id });
|
|
sourceAuditCommentId = sourceComment?.id ?? null;
|
|
await input.db
|
|
.update(issues)
|
|
.set({ updatedAt: now })
|
|
.where(eq(issues.id, input.evidence.sourceIssueId));
|
|
}
|
|
|
|
const claimantIssueId = input.evidence.contention?.claimedByIssueId ?? null;
|
|
if (claimantIssueId && claimantIssueId !== input.evidence.sourceIssueId) {
|
|
const [claimantComment] = await input.db
|
|
.insert(issueComments)
|
|
.values({
|
|
companyId: input.companyId,
|
|
issueId: claimantIssueId,
|
|
authorAgentId: null,
|
|
authorUserId: null,
|
|
authorType: "system",
|
|
createdByRunId: input.heartbeatRunId,
|
|
body,
|
|
})
|
|
.returning({ id: issueComments.id });
|
|
claimantAuditCommentId = claimantComment?.id ?? null;
|
|
await input.db
|
|
.update(issues)
|
|
.set({ updatedAt: now })
|
|
.where(eq(issues.id, claimantIssueId));
|
|
}
|
|
|
|
return { sourceAuditCommentId, claimantAuditCommentId };
|
|
}
|
|
|
|
async function logDirtyQuarantineActivity(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
evidence: GitWorktreeBranchIncoherenceEvidence;
|
|
rescueBranch: string;
|
|
rescueCommitSha: string;
|
|
fileCount: number;
|
|
heartbeatRunId: string | null;
|
|
sourceAuditCommentId: string | null;
|
|
claimantAuditCommentId: string | null;
|
|
}) {
|
|
await logActivity(input.db, {
|
|
companyId: input.companyId,
|
|
actorType: "system",
|
|
actorId: "workspace_runtime",
|
|
runId: input.heartbeatRunId,
|
|
action: "execution_workspace.dirty_worktree_quarantined",
|
|
entityType: input.evidence.executionWorkspaceId ? "execution_workspace" : "issue",
|
|
entityId: input.evidence.executionWorkspaceId ?? input.evidence.sourceIssueId ?? input.companyId,
|
|
details: {
|
|
reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON,
|
|
sourceIssueId: input.evidence.sourceIssueId,
|
|
executionWorkspaceId: input.evidence.executionWorkspaceId,
|
|
worktreePath: input.evidence.worktreePath,
|
|
expectedBranch: input.evidence.expectedBranch,
|
|
actualBranch: input.evidence.actualBranch,
|
|
rescueBranch: input.rescueBranch,
|
|
rescueCommitSha: input.rescueCommitSha,
|
|
fileCount: input.fileCount,
|
|
dirtyPathSample: input.evidence.dirtyPathSample,
|
|
fingerprint: input.evidence.fingerprint,
|
|
contention: input.evidence.contention,
|
|
sourceAuditCommentId: input.sourceAuditCommentId,
|
|
claimantAuditCommentId: input.claimantAuditCommentId,
|
|
actor: {
|
|
type: "system",
|
|
id: "workspace_runtime",
|
|
source: "workspace_runtime",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function recordDirtyQuarantineOperation(input: {
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
phase?: "worktree_prepare" | "workspace_finalize";
|
|
cwd: string;
|
|
evidence: GitWorktreeBranchIncoherenceEvidence;
|
|
rescueBranch: string;
|
|
rescueCommitSha: string;
|
|
fileCount: number;
|
|
sourceAuditCommentId: string | null;
|
|
claimantAuditCommentId: string | null;
|
|
}) {
|
|
if (!input.recorder) return;
|
|
await input.recorder.recordOperation({
|
|
phase: input.phase ?? "worktree_prepare",
|
|
cwd: input.cwd,
|
|
metadata: {
|
|
repoRoot: input.evidence.repoRoot,
|
|
worktreePath: input.evidence.worktreePath,
|
|
expectedBranchName: input.evidence.expectedBranch,
|
|
actualBranchName: input.evidence.actualBranch,
|
|
branchIncoherenceDirtyQuarantineRepair: true,
|
|
rescueBranch: input.rescueBranch,
|
|
rescueCommitSha: input.rescueCommitSha,
|
|
fileCount: input.fileCount,
|
|
dirtyPathSample: input.evidence.dirtyPathSample,
|
|
fingerprint: input.evidence.fingerprint,
|
|
sourceIssueId: input.evidence.sourceIssueId,
|
|
executionWorkspaceId: input.evidence.executionWorkspaceId,
|
|
sourceAuditCommentId: input.sourceAuditCommentId,
|
|
claimantAuditCommentId: input.claimantAuditCommentId,
|
|
},
|
|
run: async () => ({
|
|
status: "succeeded",
|
|
system:
|
|
`Quarantined dirty git worktree state on ${input.rescueBranch} (${formatShortSha(input.rescueCommitSha)}) and restored recorded branch ${input.evidence.expectedBranch}.\n`,
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function quarantineDirtyWorktreeBranchIncoherence(input: {
|
|
db: Db;
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
expectedBranchName: string;
|
|
sourceIssue: ExecutionWorkspaceIssueRef | null;
|
|
executionWorkspaceId: string | null;
|
|
heartbeatRunId: string | null;
|
|
evidence: GitWorktreeBranchIncoherenceEvidence;
|
|
phase?: "worktree_prepare" | "workspace_finalize";
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
}): Promise<DirtyQuarantineRepairResult> {
|
|
const companyId = await readIssueCompanyId(input.db, input.evidence.sourceIssueId);
|
|
if (!companyId) {
|
|
input.evidence.safeRepair.eligible = false;
|
|
input.evidence.safeRepair.reason = "dirty quarantine repair requires a source issue company for audit";
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
|
|
const freshContention = await findGitWorktreeBranchContention({
|
|
db: input.db,
|
|
sourceIssue: input.sourceIssue,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
worktreePath: input.worktreePath,
|
|
actualBranchName: input.evidence.actualBranch,
|
|
});
|
|
input.evidence.contention = freshContention;
|
|
if (freshContention) {
|
|
input.evidence.safeRepair.eligible = false;
|
|
input.evidence.safeRepair.reason = formatDirtyQuarantineContentionRefusal(freshContention);
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
|
|
const rescueBranch = buildDirtyQuarantineRescueBranch(input.sourceIssue);
|
|
const fileCount = input.evidence.statusEntryCount ?? input.evidence.dirtyPathSample.length;
|
|
const baseMetadata = {
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranchName: input.expectedBranchName,
|
|
actualBranchName: input.evidence.actualBranch,
|
|
branchIncoherenceDirtyQuarantineRepair: true,
|
|
rescueBranch,
|
|
fingerprint: input.evidence.fingerprint,
|
|
sourceIssueId: input.evidence.sourceIssueId,
|
|
executionWorkspaceId: input.evidence.executionWorkspaceId,
|
|
fileCount,
|
|
dirtyPathSample: input.evidence.dirtyPathSample,
|
|
contention: input.evidence.contention,
|
|
};
|
|
|
|
let rescueBranchCreated = false;
|
|
let expectedBranchRestored = false;
|
|
try {
|
|
await assertGitIndexIsUnlocked(input.worktreePath);
|
|
await recordGitOperation(input.recorder, {
|
|
phase: input.phase ?? "worktree_prepare",
|
|
args: ["checkout", "-b", rescueBranch],
|
|
cwd: input.worktreePath,
|
|
metadata: baseMetadata,
|
|
successMessage: `Created rescue branch ${rescueBranch} for dirty git worktree state at ${input.worktreePath}\n`,
|
|
failureLabel: `git checkout -b ${rescueBranch}`,
|
|
});
|
|
rescueBranchCreated = true;
|
|
await recordGitOperation(input.recorder, {
|
|
phase: input.phase ?? "worktree_prepare",
|
|
args: ["add", "-A"],
|
|
cwd: input.worktreePath,
|
|
metadata: baseMetadata,
|
|
successMessage: `Staged dirty git worktree state for rescue branch ${rescueBranch}\n`,
|
|
failureLabel: "git add -A",
|
|
});
|
|
await recordGitOperation(input.recorder, {
|
|
phase: input.phase ?? "worktree_prepare",
|
|
args: [
|
|
"commit",
|
|
"-m",
|
|
"Paperclip dirty workspace rescue",
|
|
"-m",
|
|
[
|
|
`Source-Issue: ${input.evidence.sourceIdentifier ?? input.evidence.sourceIssueId ?? "unknown"}`,
|
|
`Run-Id: ${input.heartbeatRunId ?? "unknown"}`,
|
|
`Recorded-Branch: ${input.expectedBranchName}`,
|
|
`Live-Branch: ${formatBranchForMessage(input.evidence.actualBranch)}`,
|
|
`Fingerprint: ${input.evidence.fingerprint}`,
|
|
].join("\n"),
|
|
],
|
|
cwd: input.worktreePath,
|
|
metadata: baseMetadata,
|
|
successMessage: `Committed dirty git worktree state to rescue branch ${rescueBranch}\n`,
|
|
failureLabel: "git commit dirty workspace rescue",
|
|
});
|
|
const rescueCommitSha = await runGit(["rev-parse", "HEAD"], input.worktreePath);
|
|
await recordGitOperation(input.recorder, {
|
|
phase: input.phase ?? "worktree_prepare",
|
|
args: ["checkout", input.expectedBranchName],
|
|
cwd: input.worktreePath,
|
|
metadata: {
|
|
...baseMetadata,
|
|
rescueCommitSha,
|
|
},
|
|
successMessage: `Restored recorded branch ${input.expectedBranchName} after dirty workspace rescue ${rescueBranch}\n`,
|
|
failureLabel: `git checkout ${input.expectedBranchName}`,
|
|
});
|
|
expectedBranchRestored = true;
|
|
|
|
// A run that died mid-rebase (or mid-merge/cherry-pick/revert/bisect)
|
|
// leaves the operation's state directory behind even after the recorded
|
|
// branch is checked out, which wedges the next git command in the
|
|
// worktree. The rescue commit above already preserved the in-flight
|
|
// resolution, so clearing the state metadata here loses nothing.
|
|
let clearedInProgressOperation: GitWorktreeInProgressOperation | null = null;
|
|
const lingeringOperation = await detectGitWorktreeInProgressOperation(input.worktreePath);
|
|
if (lingeringOperation) {
|
|
const operationLabel = GIT_IN_PROGRESS_OPERATION_LABELS[lingeringOperation];
|
|
const quitArgs = GIT_IN_PROGRESS_OPERATION_QUIT_ARGS[lingeringOperation];
|
|
await recordGitOperation(input.recorder, {
|
|
phase: input.phase ?? "worktree_prepare",
|
|
args: quitArgs,
|
|
cwd: input.worktreePath,
|
|
metadata: {
|
|
...baseMetadata,
|
|
clearedInProgressOperation: lingeringOperation,
|
|
},
|
|
successMessage: `Cleared interrupted git ${operationLabel} state after dirty workspace rescue ${rescueBranch}\n`,
|
|
failureLabel: `git ${quitArgs.join(" ")}`,
|
|
});
|
|
const stillInProgress = await detectGitWorktreeInProgressOperation(input.worktreePath);
|
|
if (stillInProgress) {
|
|
input.evidence.safeRepair.succeeded = false;
|
|
input.evidence.safeRepair.reason =
|
|
`dirty quarantine repair could not clear the interrupted git ${GIT_IN_PROGRESS_OPERATION_LABELS[stillInProgress]} state`;
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
clearedInProgressOperation = lingeringOperation;
|
|
}
|
|
|
|
const repairedBranch = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath)
|
|
.catch(() => null);
|
|
if (repairedBranch !== input.expectedBranchName) {
|
|
input.evidence.safeRepair.succeeded = false;
|
|
input.evidence.safeRepair.reason =
|
|
`dirty quarantine repair checked out ${formatBranchForMessage(repairedBranch)} instead of ${input.expectedBranchName}`;
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
const repairedStatus = await runExpensiveGitStatus({
|
|
args: ["status", "--porcelain", "--untracked-files=all"],
|
|
cwd: input.worktreePath,
|
|
operation: "workspace_runtime.dirty_quarantine_verify",
|
|
fairnessKeys: [
|
|
...(input.executionWorkspaceId ? [`workspace:${input.executionWorkspaceId}`] : []),
|
|
...(input.sourceIssue?.id ? [`issue:${input.sourceIssue.id}`] : []),
|
|
],
|
|
});
|
|
if (repairedStatus.trim().length > 0) {
|
|
input.evidence.safeRepair.succeeded = false;
|
|
input.evidence.safeRepair.reason = "dirty quarantine repair completed but the worktree is still dirty";
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
|
|
const comments = await writeDirtyQuarantineAuditComments({
|
|
db: input.db,
|
|
companyId,
|
|
evidence: input.evidence,
|
|
sourceIssue: input.sourceIssue,
|
|
rescueBranch,
|
|
rescueCommitSha,
|
|
fileCount,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
});
|
|
await logDirtyQuarantineActivity({
|
|
db: input.db,
|
|
companyId,
|
|
evidence: input.evidence,
|
|
rescueBranch,
|
|
rescueCommitSha,
|
|
fileCount,
|
|
heartbeatRunId: input.heartbeatRunId,
|
|
sourceAuditCommentId: comments.sourceAuditCommentId,
|
|
claimantAuditCommentId: comments.claimantAuditCommentId,
|
|
});
|
|
await recordDirtyQuarantineOperation({
|
|
recorder: input.recorder,
|
|
phase: input.phase,
|
|
cwd: input.worktreePath,
|
|
evidence: input.evidence,
|
|
rescueBranch,
|
|
rescueCommitSha,
|
|
fileCount,
|
|
sourceAuditCommentId: comments.sourceAuditCommentId,
|
|
claimantAuditCommentId: comments.claimantAuditCommentId,
|
|
});
|
|
return {
|
|
rescueBranch,
|
|
rescueCommitSha,
|
|
fileCount,
|
|
clearedInProgressOperation,
|
|
...comments,
|
|
};
|
|
} catch (error) {
|
|
if (rescueBranchCreated && !expectedBranchRestored) {
|
|
await runGit(["checkout", input.expectedBranchName], input.worktreePath).catch(() => null);
|
|
}
|
|
if (error instanceof WorkspaceRuntimeValidationFailure) throw error;
|
|
input.evidence.safeRepair.succeeded = false;
|
|
input.evidence.safeRepair.reason = formatDirtyQuarantineFailure(error);
|
|
throw branchIncoherenceValidationFailure(input.evidence);
|
|
}
|
|
}
|
|
|
|
async function recordForwardBranchReconcileOperation(input: {
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
phase?: "worktree_prepare" | "workspace_finalize";
|
|
cwd: string;
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
expectedBranchName: string;
|
|
actualBranchName: string;
|
|
executionWorkspaceId: string | null;
|
|
sourceIssueId: string | null;
|
|
fingerprint: string;
|
|
expectedHeadSha: string | null;
|
|
actualHeadSha: string | null;
|
|
ancestryVerdict: GitWorktreeBranchAncestryVerdict;
|
|
mode: "record_updated" | "adopt_for_realize";
|
|
auditCommentId?: string | null;
|
|
recoveryActionId?: string | null;
|
|
}) {
|
|
if (!input.recorder) return;
|
|
|
|
await input.recorder.recordOperation({
|
|
phase: input.phase ?? "worktree_prepare",
|
|
cwd: input.cwd,
|
|
metadata: {
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranchName: input.expectedBranchName,
|
|
actualBranchName: input.actualBranchName,
|
|
branchIncoherenceReconcileForward: true,
|
|
reconcileMode: input.mode,
|
|
fingerprint: input.fingerprint,
|
|
sourceIssueId: input.sourceIssueId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
expectedHeadSha: input.expectedHeadSha,
|
|
actualHeadSha: input.actualHeadSha,
|
|
ancestryVerdict: input.ancestryVerdict,
|
|
auditCommentId: input.auditCommentId ?? null,
|
|
recoveryActionId: input.recoveryActionId ?? null,
|
|
},
|
|
run: async () => ({
|
|
status: "succeeded",
|
|
system:
|
|
input.mode === "record_updated"
|
|
? `Reconciled execution workspace branch record from ${input.expectedBranchName} to ${input.actualBranchName}; worktree left unchanged.\n`
|
|
: `Adopted live git worktree branch ${input.actualBranchName} for this execution workspace realization; worktree left unchanged.\n`,
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function logForwardBranchReconcileActivity(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
executionWorkspaceId: string;
|
|
sourceIssueId: string | null;
|
|
runId: string | null;
|
|
mode: "forward";
|
|
reason: string | null;
|
|
fromBranch: string;
|
|
toBranch: string;
|
|
fromSha: string | null;
|
|
toSha: string | null;
|
|
ancestryVerdict: GitWorktreeBranchAncestryVerdict;
|
|
fingerprint: string;
|
|
auditCommentId: string | null;
|
|
recoveryActionId: string | null;
|
|
}) {
|
|
await logActivity(input.db, {
|
|
companyId: input.companyId,
|
|
actorType: "system",
|
|
actorId: "workspace_runtime",
|
|
runId: input.runId,
|
|
action: "execution_workspace.branch_reconciled",
|
|
entityType: "execution_workspace",
|
|
entityId: input.executionWorkspaceId,
|
|
details: {
|
|
mode: input.mode,
|
|
reason: input.reason,
|
|
fromBranch: input.fromBranch,
|
|
toBranch: input.toBranch,
|
|
fromSha: input.fromSha,
|
|
toSha: input.toSha,
|
|
ancestryVerdict: input.ancestryVerdict,
|
|
fingerprint: input.fingerprint,
|
|
sourceIssueId: input.sourceIssueId,
|
|
auditCommentId: input.auditCommentId,
|
|
recoveryActionId: input.recoveryActionId,
|
|
actor: {
|
|
type: "system",
|
|
id: "workspace_runtime",
|
|
source: "workspace_runtime",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function reconcilePendingForwardBranchAfterPersistence(input: {
|
|
db: Db;
|
|
executionWorkspaceId: string;
|
|
pending: PendingForwardBranchReconcile;
|
|
heartbeatRunId?: string | null;
|
|
reconcileOperationPhase?: "worktree_prepare" | "workspace_finalize";
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
}) {
|
|
const result = await executionWorkspaceService(input.db).reconcileExecutionWorkspaceBranch(
|
|
input.executionWorkspaceId,
|
|
{
|
|
mode: "forward",
|
|
reason: input.pending.reason,
|
|
alternateRecoveryFingerprints: [input.pending.prePersistenceFingerprint],
|
|
actor: {
|
|
actorType: "system",
|
|
actorId: "workspace_runtime",
|
|
agentId: null,
|
|
runId: input.heartbeatRunId ?? null,
|
|
},
|
|
},
|
|
);
|
|
await logForwardBranchReconcileActivity({
|
|
db: input.db,
|
|
companyId: result.workspace.companyId,
|
|
executionWorkspaceId: result.workspace.id,
|
|
sourceIssueId: result.workspace.sourceIssueId,
|
|
runId: input.heartbeatRunId ?? null,
|
|
mode: "forward",
|
|
reason: input.pending.reason,
|
|
fromBranch: result.inspection.fromBranch,
|
|
toBranch: result.inspection.toBranch,
|
|
fromSha: result.inspection.fromSha,
|
|
toSha: result.inspection.toSha,
|
|
ancestryVerdict: result.inspection.ancestryVerdict,
|
|
fingerprint: result.inspection.fingerprint,
|
|
auditCommentId: result.auditCommentId,
|
|
recoveryActionId: result.recoveryAction?.id ?? null,
|
|
});
|
|
await recordForwardBranchReconcileOperation({
|
|
recorder: input.recorder,
|
|
phase: input.reconcileOperationPhase,
|
|
cwd: result.inspection.worktreePath,
|
|
repoRoot: result.inspection.repoRoot,
|
|
worktreePath: result.inspection.worktreePath,
|
|
expectedBranchName: result.inspection.fromBranch,
|
|
actualBranchName: result.inspection.toBranch,
|
|
executionWorkspaceId: result.workspace.id,
|
|
sourceIssueId: result.workspace.sourceIssueId,
|
|
fingerprint: result.inspection.fingerprint,
|
|
expectedHeadSha: result.inspection.fromSha,
|
|
actualHeadSha: result.inspection.toSha,
|
|
ancestryVerdict: result.inspection.ancestryVerdict,
|
|
mode: "adopt_for_realize",
|
|
auditCommentId: result.auditCommentId,
|
|
recoveryActionId: result.recoveryAction?.id ?? null,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function ensureGitWorktreeBranchCoherent(input: {
|
|
db?: Db | null;
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
expectedBranchName: string | null;
|
|
sourceIssue: ExecutionWorkspaceIssueRef | null;
|
|
executionWorkspaceId?: string | null;
|
|
actualBranchName?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
enableWorkspaceBranchReconcileForward?: boolean;
|
|
enableWorkspaceDirtyQuarantineRepair?: boolean;
|
|
persistForwardReconcile?: boolean;
|
|
reconcileOperationPhase?: "worktree_prepare" | "workspace_finalize";
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
}): Promise<GitWorktreeBranchCoherenceResult> {
|
|
const expectedBranchName = input.expectedBranchName?.trim();
|
|
if (!expectedBranchName) return { branchName: null, reconciledForward: false, warnings: [] };
|
|
|
|
const currentBranch = input.actualBranchName !== undefined
|
|
? input.actualBranchName
|
|
: await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath).catch(() => null);
|
|
if (currentBranch === expectedBranchName) {
|
|
return { branchName: expectedBranchName, reconciledForward: false, warnings: [] };
|
|
}
|
|
|
|
const evidence = await inspectGitWorktreeBranchIncoherence({
|
|
db: input.db ?? null,
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranchName,
|
|
actualBranchName: currentBranch,
|
|
sourceIssue: input.sourceIssue,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
});
|
|
|
|
if (evidence.cleanliness === "dirty" && input.enableWorkspaceDirtyQuarantineRepair === true) {
|
|
if (!input.db) {
|
|
evidence.safeRepair.reason = "dirty quarantine repair requires database access for claimant checks and audit";
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
if (!evidence.provenance.registeredPathFound) {
|
|
evidence.safeRepair.reason = "dirty quarantine repair requires a registered git worktree path";
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
if (!evidence.provenance.expectedBranchExists) {
|
|
evidence.safeRepair.reason = "dirty quarantine repair requires the recorded branch to exist";
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
if (evidence.contention) {
|
|
evidence.safeRepair.eligible = false;
|
|
evidence.safeRepair.reason = formatDirtyQuarantineContentionRefusal(evidence.contention);
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
await assertDirtyQuarantineRuntimeServicesStopped({
|
|
db: input.db,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
evidence,
|
|
});
|
|
evidence.safeRepair.eligible = true;
|
|
evidence.safeRepair.attempted = true;
|
|
evidence.safeRepair.reason = "dirty worktree can be quarantined on a rescue branch before restoring the recorded branch";
|
|
const result = await quarantineDirtyWorktreeBranchIncoherence({
|
|
db: input.db,
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranchName,
|
|
sourceIssue: input.sourceIssue,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
heartbeatRunId: input.heartbeatRunId ?? null,
|
|
evidence,
|
|
phase: input.reconcileOperationPhase,
|
|
recorder: input.recorder ?? null,
|
|
});
|
|
evidence.safeRepair.succeeded = true;
|
|
evidence.safeRepair.reason = result.clearedInProgressOperation
|
|
? `dirty worktree quarantined on ${result.rescueBranch} at ${formatShortSha(result.rescueCommitSha)}; interrupted git ${GIT_IN_PROGRESS_OPERATION_LABELS[result.clearedInProgressOperation]} state cleared`
|
|
: `dirty worktree quarantined on ${result.rescueBranch} at ${formatShortSha(result.rescueCommitSha)}`;
|
|
return {
|
|
branchName: expectedBranchName,
|
|
reconciledForward: false,
|
|
dirtyQuarantineRepair: result,
|
|
warnings: [
|
|
`Execution workspace dirty worktree state was quarantined on rescue branch "${result.rescueBranch}" (${formatShortSha(result.rescueCommitSha)}; ${result.fileCount} ${result.fileCount === 1 ? "file" : "files"}) before restoring recorded branch "${expectedBranchName}".${result.clearedInProgressOperation ? ` An interrupted git ${GIT_IN_PROGRESS_OPERATION_LABELS[result.clearedInProgressOperation]} was also cleared; its in-flight state is preserved on the rescue branch.` : ""}`,
|
|
],
|
|
};
|
|
}
|
|
|
|
// A recorded branch that no longer exists anywhere has no commits to lose, so
|
|
// adopting the clean checked-out branch is trivially forward-only. This is the
|
|
// steady state left behind when an agent renames its task branch (e.g. to a
|
|
// feat/* PR branch) and the recorded branch was never created or was deleted.
|
|
const recordedBranchMissingButAdoptable =
|
|
!evidence.provenance.expectedBranchExists &&
|
|
evidence.provenance.actualBranchExists === true &&
|
|
evidence.provenance.registeredBranchMatchesHead;
|
|
if (
|
|
input.enableWorkspaceBranchReconcileForward === true &&
|
|
evidence.cleanliness === "clean" &&
|
|
currentBranch &&
|
|
((evidence.provenance.ancestryVerdict === "ancestor" && !evidence.provenance.sameHead) ||
|
|
recordedBranchMissingButAdoptable)
|
|
) {
|
|
const reason = evidence.provenance.expectedBranchExists
|
|
? "Automatic forward reconciliation: recorded branch is an ancestor of the checked-out branch."
|
|
: "Automatic forward reconciliation: the recorded branch no longer exists, so Paperclip adopted the clean checked-out branch.";
|
|
if (input.executionWorkspaceId && input.persistForwardReconcile !== false) {
|
|
if (!input.db) {
|
|
evidence.safeRepair.reason = "forward reconciliation requires database access to update the execution workspace record";
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
try {
|
|
const result = await executionWorkspaceService(input.db).reconcileExecutionWorkspaceBranch(
|
|
input.executionWorkspaceId,
|
|
{
|
|
mode: "forward",
|
|
reason,
|
|
actor: {
|
|
actorType: "system",
|
|
actorId: "workspace_runtime",
|
|
agentId: null,
|
|
runId: input.heartbeatRunId ?? null,
|
|
},
|
|
},
|
|
);
|
|
await logForwardBranchReconcileActivity({
|
|
db: input.db,
|
|
companyId: result.workspace.companyId,
|
|
executionWorkspaceId: result.workspace.id,
|
|
sourceIssueId: result.workspace.sourceIssueId ?? evidence.sourceIssueId ?? null,
|
|
runId: input.heartbeatRunId ?? null,
|
|
mode: "forward",
|
|
reason,
|
|
fromBranch: result.inspection.fromBranch,
|
|
toBranch: result.inspection.toBranch,
|
|
fromSha: result.inspection.fromSha,
|
|
toSha: result.inspection.toSha,
|
|
ancestryVerdict: result.inspection.ancestryVerdict,
|
|
fingerprint: result.inspection.fingerprint,
|
|
auditCommentId: result.auditCommentId,
|
|
recoveryActionId: result.recoveryAction?.id ?? null,
|
|
});
|
|
await recordForwardBranchReconcileOperation({
|
|
recorder: input.recorder,
|
|
phase: input.reconcileOperationPhase,
|
|
cwd: input.worktreePath,
|
|
repoRoot: result.inspection.repoRoot,
|
|
worktreePath: result.inspection.worktreePath,
|
|
expectedBranchName: result.inspection.fromBranch,
|
|
actualBranchName: result.inspection.toBranch,
|
|
executionWorkspaceId: result.workspace.id,
|
|
sourceIssueId: result.workspace.sourceIssueId ?? evidence.sourceIssueId ?? null,
|
|
fingerprint: result.inspection.fingerprint,
|
|
expectedHeadSha: result.inspection.fromSha,
|
|
actualHeadSha: result.inspection.toSha,
|
|
ancestryVerdict: result.inspection.ancestryVerdict,
|
|
mode: "record_updated",
|
|
auditCommentId: result.auditCommentId,
|
|
recoveryActionId: result.recoveryAction?.id ?? null,
|
|
});
|
|
return { branchName: result.inspection.toBranch, reconciledForward: true, warnings: [] };
|
|
} catch (error) {
|
|
evidence.safeRepair.reason =
|
|
`forward reconciliation failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
}
|
|
|
|
if (!input.db) {
|
|
evidence.safeRepair.reason = "forward reconciliation adoption requires database access to audit after workspace realization";
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
return {
|
|
branchName: currentBranch,
|
|
reconciledForward: true,
|
|
warnings: [],
|
|
pendingForwardBranchReconcile: {
|
|
recordedBranchName: expectedBranchName,
|
|
adoptedBranchName: currentBranch,
|
|
prePersistenceFingerprint: evidence.fingerprint,
|
|
reason,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (!evidence.safeRepair.eligible) {
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
|
|
evidence.safeRepair.attempted = true;
|
|
const warningPrefix =
|
|
`Execution workspace branch metadata was self-healed from "${expectedBranchName}" to "${formatBranchForMessage(currentBranch)}" at ${input.worktreePath}.`;
|
|
if (
|
|
currentBranch &&
|
|
evidence.provenance.actualBranchExists === true &&
|
|
evidence.provenance.ancestryVerdict === "ancestor" &&
|
|
!evidence.provenance.sameHead
|
|
) {
|
|
evidence.safeRepair.succeeded = true;
|
|
evidence.safeRepair.reason = "clean worktree adopted the checked-out branch because it is forward of the recorded branch";
|
|
return {
|
|
branchName: currentBranch,
|
|
reconciledForward: false,
|
|
warnings: [
|
|
`${warningPrefix} The checked-out branch contains the recorded branch plus newer commits, so Paperclip adopted it for subsequent runs.`,
|
|
],
|
|
};
|
|
}
|
|
|
|
if (
|
|
currentBranch === null &&
|
|
evidence.provenance.ancestryVerdict === "ancestor" &&
|
|
!evidence.provenance.sameHead &&
|
|
evidence.provenance.actualHeadSha
|
|
) {
|
|
try {
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_prepare",
|
|
args: ["checkout", "-B", expectedBranchName, evidence.provenance.actualHeadSha],
|
|
cwd: input.worktreePath,
|
|
metadata: {
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranchName,
|
|
actualBranchName: currentBranch,
|
|
branchIncoherenceRepair: true,
|
|
detachedHeadRepair: true,
|
|
fingerprint: evidence.fingerprint,
|
|
sourceIssueId: evidence.sourceIssueId,
|
|
executionWorkspaceId: evidence.executionWorkspaceId,
|
|
},
|
|
successMessage: `Reattached detached git worktree HEAD at ${input.worktreePath} to ${expectedBranchName}\n`,
|
|
failureLabel: `git checkout -B ${expectedBranchName} ${formatShortSha(evidence.provenance.actualHeadSha)}`,
|
|
});
|
|
} catch (error) {
|
|
evidence.safeRepair.succeeded = false;
|
|
evidence.safeRepair.reason = `safe detached HEAD reattachment failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
|
|
const repairedBranch = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath)
|
|
.catch(() => null);
|
|
if (repairedBranch !== expectedBranchName) {
|
|
evidence.safeRepair.succeeded = false;
|
|
evidence.safeRepair.reason = `reattach completed but HEAD is ${formatBranchForMessage(repairedBranch)}`;
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
|
|
evidence.safeRepair.succeeded = true;
|
|
evidence.safeRepair.reason = "clean detached worktree HEAD was reattached to the recorded branch";
|
|
return {
|
|
branchName: expectedBranchName,
|
|
reconciledForward: false,
|
|
warnings: [
|
|
`${warningPrefix} The detached HEAD contained the recorded branch plus newer commits, so Paperclip moved the recorded branch to that HEAD.`,
|
|
],
|
|
};
|
|
}
|
|
|
|
try {
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_prepare",
|
|
args: ["checkout", expectedBranchName],
|
|
cwd: input.worktreePath,
|
|
metadata: {
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranchName,
|
|
actualBranchName: currentBranch,
|
|
branchIncoherenceRepair: true,
|
|
fingerprint: evidence.fingerprint,
|
|
sourceIssueId: evidence.sourceIssueId,
|
|
executionWorkspaceId: evidence.executionWorkspaceId,
|
|
},
|
|
successMessage: `Repaired clean git worktree branch mismatch at ${input.worktreePath}: checked out ${expectedBranchName}\n`,
|
|
failureLabel: `git checkout ${expectedBranchName}`,
|
|
});
|
|
} catch (error) {
|
|
evidence.safeRepair.succeeded = false;
|
|
evidence.safeRepair.reason = `safe checkout failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
|
|
const repairedBranch = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath)
|
|
.catch(() => null);
|
|
if (repairedBranch !== expectedBranchName) {
|
|
evidence.safeRepair.succeeded = false;
|
|
evidence.safeRepair.reason = `checkout completed but HEAD is ${formatBranchForMessage(repairedBranch)}`;
|
|
throw branchIncoherenceValidationFailure(evidence);
|
|
}
|
|
|
|
evidence.safeRepair.succeeded = true;
|
|
evidence.safeRepair.reason = "clean worktree checked out the recorded branch";
|
|
return {
|
|
branchName: expectedBranchName,
|
|
reconciledForward: false,
|
|
warnings: [
|
|
`Execution workspace branch metadata was self-healed by checking out recorded branch "${expectedBranchName}" at ${input.worktreePath}.`,
|
|
],
|
|
};
|
|
}
|
|
|
|
// A configured base ref that does not resolve to a commit, even after an
|
|
// attempted fetch of its remote-tracking counterpart. The caller must
|
|
// stop before `git worktree add` and raise a pre-dispatch configuration
|
|
// failure. `requestedRef` keeps the operator spelling for the human notice.
|
|
// `recoveryIdentityRef` is the canonical remote ref the resolver probed, so two
|
|
// equivalent spellings of one remote branch map to one recovery identity.
|
|
// `attemptedRefs` names each ref the resolver tried, and `fetchError` carries
|
|
// the first fetch warning (masked) when the fetch itself failed.
|
|
export class UnresolvedWorkspaceBaseRefError extends Error {
|
|
requestedRef: string;
|
|
recoveryIdentityRef: string;
|
|
attemptedRefs: string[];
|
|
fetchError: string | null;
|
|
defaultBranch: string | null;
|
|
|
|
constructor(input: {
|
|
requestedRef: string;
|
|
recoveryIdentityRef: string;
|
|
attemptedRefs: string[];
|
|
fetchError?: string | null;
|
|
defaultBranch?: string | null;
|
|
}) {
|
|
super(
|
|
`Configured workspace base ref "${input.requestedRef}" could not be resolved to a commit ` +
|
|
`(tried: ${input.attemptedRefs.join(", ")}). Check that the ref exists and the repository is accessible before retrying.`,
|
|
);
|
|
this.name = "UnresolvedWorkspaceBaseRefError";
|
|
this.requestedRef = input.requestedRef;
|
|
this.recoveryIdentityRef = input.recoveryIdentityRef;
|
|
this.attemptedRefs = input.attemptedRefs;
|
|
this.fetchError = input.fetchError ?? null;
|
|
this.defaultBranch = input.defaultBranch ?? null;
|
|
}
|
|
}
|
|
|
|
const unresolvedBaseRefDiagnostics = new WeakMap<Error, WorkspaceBaseRefDiagnostic>();
|
|
|
|
/** Reads only the actual resolver's private receipt, not similarly named fields. */
|
|
export function readUnresolvedWorkspaceBaseRefDiagnostic(error: unknown): WorkspaceBaseRefDiagnostic | null {
|
|
return error instanceof Error ? readWorkspaceBaseRefDiagnostic(unresolvedBaseRefDiagnostics.get(error)) : null;
|
|
}
|
|
|
|
export function isUnresolvedWorkspaceBaseRefError(error: unknown): error is UnresolvedWorkspaceBaseRefError {
|
|
return error instanceof UnresolvedWorkspaceBaseRefError;
|
|
}
|
|
|
|
// A resolved base ref that the caller can pass to `git worktree add`, or an
|
|
// unresolved outcome that must stop the caller before it creates the worktree.
|
|
type AuthoritativeBaseRefResolution =
|
|
| { resolved: true; baseRef: string; warnings: string[]; refreshed: boolean }
|
|
| {
|
|
resolved: false;
|
|
requestedRef: string;
|
|
// The canonical remote ref the resolver probed for this branch, for
|
|
// example `origin/fix/foo`. Two equivalent spellings of one remote branch
|
|
// (`fix/foo` and `origin/fix/foo`) share this value, so recovery treats
|
|
// them as one identity. Two different branches get different values.
|
|
recoveryIdentityRef: string;
|
|
attemptedRefs: string[];
|
|
warnings: string[];
|
|
fetchError: string | null;
|
|
diagnostic: WorkspaceBaseRefDiagnostic;
|
|
};
|
|
|
|
// Resolve the authoritative base ref for a fresh worktree. A configured local
|
|
// branch is mapped to its `origin/<branch>` counterpart so unpushed local
|
|
// divergence never leaks into the task branch; SHAs and tags are used verbatim,
|
|
// and an unset/`HEAD` base falls back to the detected default branch (which
|
|
// already prefers `origin/master`).
|
|
//
|
|
// A remote-only feature branch never has a local ref or a remote-tracking ref
|
|
// yet. The resolver fetches `origin/<branch>` with the authenticated helper,
|
|
// then re-checks the commit. This covers both the unqualified form (`fix/foo`)
|
|
// and the remote-tracking form (`origin/fix/foo`). A ref that still does not
|
|
// resolve returns `resolved: false`, so the caller stops before the worktree
|
|
// add instead of passing an invalid reference to git.
|
|
async function resolveAuthoritativeBaseRef(
|
|
repoRoot: string,
|
|
configuredBaseRef: string | null,
|
|
resolveGitAuth?: GitRemoteAuthProvider | null,
|
|
): Promise<AuthoritativeBaseRefResolution> {
|
|
const warnings: string[] = [];
|
|
const detectOrHead = async () => (await detectDefaultBranch(repoRoot, resolveGitAuth)) ?? "HEAD";
|
|
|
|
const configured = configuredBaseRef?.trim();
|
|
if (!configured || configured === "HEAD") {
|
|
return { resolved: true, baseRef: await detectOrHead(), warnings, refreshed: false };
|
|
}
|
|
|
|
// A remote-tracking ref supplied directly (for example `origin/fix/foo`).
|
|
// Use it verbatim when it already resolves. When it does not, fetch it once
|
|
// and re-check, then stop if it is still absent on the remote.
|
|
//
|
|
// `parseRemoteTrackingRef` only checks the `remote/branch` shape. An
|
|
// unqualified branch name that contains a slash (for example `fix/foo`) has
|
|
// the same shape but names no real remote, so it is not a remote-tracking
|
|
// ref. Gate this branch on the first segment naming an existing remote, and
|
|
// let a name like `fix/foo` fall through to the remote-only branch handling
|
|
// below.
|
|
const remoteTracking = parseRemoteTrackingRef(configured);
|
|
if (remoteTracking && await resolveBaseRefSha(repoRoot, configured)) {
|
|
return { resolved: true, baseRef: configured, warnings, refreshed: false };
|
|
}
|
|
if (remoteTracking && await remoteExists(repoRoot, remoteTracking.remote)) {
|
|
const { warnings: fetchWarnings, diagnostic } = await refreshRemoteTrackingBaseRefWithDiagnostic(repoRoot, configured, resolveGitAuth);
|
|
warnings.push(...fetchWarnings);
|
|
if (await resolveBaseRefSha(repoRoot, configured, diagnostic)) {
|
|
return { resolved: true, baseRef: configured, warnings, refreshed: true };
|
|
}
|
|
// Build the recovery identity from the parsed remote and branch. The raw
|
|
// ref and its remote-tracking spelling (`origin/fix/foo` and
|
|
// `refs/remotes/origin/fix/foo`) then share one recovery fingerprint, so
|
|
// recovery treats them as one identity instead of two.
|
|
const canonicalRemoteRef = `${remoteTracking.remote}/${remoteTracking.branch}`;
|
|
return {
|
|
resolved: false,
|
|
requestedRef: configured,
|
|
recoveryIdentityRef: canonicalRemoteRef,
|
|
attemptedRefs: [configured],
|
|
warnings,
|
|
fetchError: fetchWarnings[0] ?? null,
|
|
diagnostic,
|
|
};
|
|
}
|
|
|
|
if (await localBranchExists(repoRoot, configured)) {
|
|
const remoteCandidate = `origin/${configured}`;
|
|
// Refresh here and keep the warnings; the caller skips its own refresh of
|
|
// the returned ref (see `refreshed`) so we never fetch the same ref twice.
|
|
warnings.push(...await refreshRemoteTrackingBaseRef(repoRoot, remoteCandidate, resolveGitAuth));
|
|
if (await resolveBaseRefSha(repoRoot, remoteCandidate)) {
|
|
return { resolved: true, baseRef: remoteCandidate, warnings, refreshed: true };
|
|
}
|
|
if (await remoteExists(repoRoot, "origin")) {
|
|
warnings.push(
|
|
`Configured base ref "${configured}" is a local branch with no matching origin/${configured}; basing the execution workspace on the local ref, which may include unpushed commits.`,
|
|
);
|
|
}
|
|
return { resolved: true, baseRef: configured, warnings, refreshed: false };
|
|
}
|
|
|
|
// Fall-through: an unqualified ref (for example `fix/foo`) that is not `HEAD`,
|
|
// not a remote-tracking ref, and not a local branch. A full SHA or a tag that
|
|
// already resolves stays verbatim. Otherwise treat it as a remote-only branch
|
|
// name: fetch `origin/<ref>` and base the worktree on the remote counterpart.
|
|
if (await resolveBaseRefSha(repoRoot, configured)) {
|
|
return { resolved: true, baseRef: configured, warnings, refreshed: false };
|
|
}
|
|
const remoteCandidate = `origin/${configured}`;
|
|
const { warnings: fetchWarnings, diagnostic } = await refreshRemoteTrackingBaseRefWithDiagnostic(repoRoot, remoteCandidate, resolveGitAuth);
|
|
warnings.push(...fetchWarnings);
|
|
if (await resolveBaseRefSha(repoRoot, remoteCandidate, diagnostic)) {
|
|
return { resolved: true, baseRef: remoteCandidate, warnings, refreshed: true };
|
|
}
|
|
return {
|
|
resolved: false,
|
|
requestedRef: configured,
|
|
recoveryIdentityRef: remoteCandidate,
|
|
attemptedRefs: [remoteCandidate],
|
|
warnings,
|
|
fetchError: fetchWarnings[0] ?? null,
|
|
diagnostic,
|
|
};
|
|
}
|
|
|
|
// Auto-refresh a reused worktree to the latest base only when it is provably
|
|
// unstarted: no task commits past the base and a clean tree (including untracked
|
|
// files). This pulls an idle worktree forward to the freshest `origin/master`
|
|
// after a long planning phase without ever destroying in-progress work. Only
|
|
// remote-tracking bases are eligible; local-only bases keep warn-only drift.
|
|
async function refreshUnstartedWorktreeToBase(input: {
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
branchName: string | null;
|
|
baseRef: string;
|
|
currentBaseRefSha: string;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
}): Promise<{ refreshed: boolean; baseRefSha: string | null }> {
|
|
if (!parseRemoteTrackingRef(input.baseRef)) {
|
|
return { refreshed: false, baseRefSha: null };
|
|
}
|
|
|
|
const headSha = await runGit(["rev-parse", "HEAD"], input.worktreePath).catch(() => null);
|
|
if (!headSha) {
|
|
return { refreshed: false, baseRefSha: null };
|
|
}
|
|
if (headSha === input.currentBaseRefSha) {
|
|
return { refreshed: false, baseRefSha: input.currentBaseRefSha };
|
|
}
|
|
|
|
const commitsPastBaseRaw = await runGit(
|
|
["rev-list", "--count", `${input.currentBaseRefSha}..HEAD`],
|
|
input.worktreePath,
|
|
).catch(() => null);
|
|
const commitsPastBase = commitsPastBaseRaw === null ? null : Number.parseInt(commitsPastBaseRaw, 10);
|
|
if (commitsPastBase === null || !Number.isFinite(commitsPastBase) || commitsPastBase > 0) {
|
|
return { refreshed: false, baseRefSha: null };
|
|
}
|
|
|
|
// Force `--untracked-files=all` so untracked files are counted regardless of a
|
|
// local `status.showUntrackedFiles=no`; otherwise the clean-tree guard could
|
|
// pass and the `reset --hard` below would destroy untracked work.
|
|
const status = await runExpensiveGitStatus({
|
|
args: ["status", "--porcelain", "--untracked-files=all"],
|
|
cwd: input.worktreePath,
|
|
operation: "workspace_runtime.base_refresh_clean_guard",
|
|
fairnessKeys: [
|
|
...(input.branchName ? [`branch:${input.branchName}`] : []),
|
|
],
|
|
}).catch(() => null);
|
|
if (status === null || status.trim().length > 0) {
|
|
return { refreshed: false, baseRefSha: null };
|
|
}
|
|
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_prepare",
|
|
args: ["reset", "--hard", input.currentBaseRefSha],
|
|
cwd: input.worktreePath,
|
|
metadata: {
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
branchName: input.branchName,
|
|
baseRef: input.baseRef,
|
|
previousHeadSha: headSha,
|
|
baseRefSha: input.currentBaseRefSha,
|
|
refreshedUnstartedWorktree: true,
|
|
},
|
|
successMessage: `Refreshed unstarted git worktree at ${input.worktreePath} to ${input.baseRef} (${formatShortSha(input.currentBaseRefSha)})\n`,
|
|
failureLabel: `git reset --hard ${input.currentBaseRefSha}`,
|
|
});
|
|
|
|
return { refreshed: true, baseRefSha: input.currentBaseRefSha };
|
|
}
|
|
|
|
|
|
type GitWorktreeListEntry = {
|
|
worktree: string;
|
|
branch: string | null;
|
|
};
|
|
|
|
export type ManagedGitWorktreeBranchInspection = {
|
|
valid: boolean;
|
|
reason: string | null;
|
|
reasonCode: typeof MANAGED_GIT_WORKTREE_REASON_CODES[number] | null;
|
|
inspectionDiagnostic?: ManagedGitInspectionDiagnostic;
|
|
repoRoot: string | null;
|
|
worktreePath: string;
|
|
expectedBranchName: string | null;
|
|
actualBranchName: string | null;
|
|
};
|
|
|
|
function parseGitWorktreeListPorcelain(raw: string): GitWorktreeListEntry[] {
|
|
const entries: GitWorktreeListEntry[] = [];
|
|
let current: Partial<GitWorktreeListEntry> = {};
|
|
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
if (line.startsWith("worktree ")) {
|
|
current = { worktree: line.slice("worktree ".length) };
|
|
continue;
|
|
}
|
|
if (line.startsWith("branch ")) {
|
|
current.branch = line.slice("branch ".length);
|
|
continue;
|
|
}
|
|
if (line === "" && current.worktree) {
|
|
entries.push({
|
|
worktree: current.worktree,
|
|
branch: current.branch ?? null,
|
|
});
|
|
current = {};
|
|
}
|
|
}
|
|
|
|
if (current.worktree) {
|
|
entries.push({
|
|
worktree: current.worktree,
|
|
branch: current.branch ?? null,
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
async function resolveGitOwnerRepoRoot(cwd: string): Promise<string> {
|
|
const checkoutRoot = path.resolve(await runGit(["rev-parse", "--show-toplevel"], cwd));
|
|
const commonDir = await runGit(["rev-parse", "--git-common-dir"], checkoutRoot).catch(() => null);
|
|
if (!commonDir) return checkoutRoot;
|
|
return path.dirname(path.resolve(checkoutRoot, commonDir));
|
|
}
|
|
|
|
async function findRegisteredGitWorktreeByBranch(repoRoot: string, branchName: string): Promise<string | null> {
|
|
const raw = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => null);
|
|
if (!raw) return null;
|
|
|
|
const expectedBranchRef = `refs/heads/${branchName}`;
|
|
for (const entry of parseGitWorktreeListPorcelain(raw)) {
|
|
if (entry.branch !== expectedBranchRef) continue;
|
|
return path.resolve(entry.worktree);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function findRegisteredGitWorktreeByPath(repoRoot: string, worktreePath: string): Promise<GitWorktreeListEntry | null> {
|
|
const raw = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => null);
|
|
if (!raw) return null;
|
|
|
|
const expectedPath = await resolvePathForWorktreeComparison(worktreePath);
|
|
for (const entry of parseGitWorktreeListPorcelain(raw)) {
|
|
if (await resolvePathForWorktreeComparison(entry.worktree) === expectedPath) {
|
|
return entry;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function isGitCheckout(cwd: string): Promise<boolean> {
|
|
return Boolean(await runGit(["rev-parse", "--git-dir"], cwd).catch(() => null));
|
|
}
|
|
|
|
// A repair suggestion must come from the remote's advertised HEAD, never the
|
|
// runtime's main/master fallback heuristic. Failure to inspect is not a guess.
|
|
async function readAdvertisedDefaultBranch(repoRoot: string, resolveGitAuth?: GitRemoteAuthProvider | null): Promise<string | null> {
|
|
try {
|
|
const remoteUrl = await runGit(["remote", "get-url", "origin"], repoRoot);
|
|
const auth = resolveGitAuth ? await resolveGitAuth(remoteUrl) : null;
|
|
const output = await new Promise<string>((resolve, reject) => {
|
|
execFile("git", [...(auth?.configArgs ?? []), "ls-remote", "--symref", "origin", "HEAD"], {
|
|
cwd: repoRoot, timeout: 10_000, maxBuffer: 64 * 1024,
|
|
env: { ...process.env, ...auth?.env, GIT_TERMINAL_PROMPT: "0" },
|
|
}, (error, stdout) => error ? reject(error) : resolve(stdout));
|
|
});
|
|
return /^ref: refs\/heads\/(.+)\tHEAD$/m.exec(output)?.[1] ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function detectDefaultBranch(
|
|
repoRoot: string,
|
|
resolveGitAuth?: GitRemoteAuthProvider | null,
|
|
): Promise<string | null> {
|
|
const originMasterRef = "origin/master";
|
|
await refreshRemoteTrackingBaseRef(repoRoot, originMasterRef, resolveGitAuth);
|
|
if (await resolveBaseRefSha(repoRoot, originMasterRef)) {
|
|
return originMasterRef;
|
|
}
|
|
|
|
// Try the explicit remote HEAD first (set by git clone or git remote set-head)
|
|
try {
|
|
const remoteHead = await runGit(
|
|
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
repoRoot,
|
|
);
|
|
if (remoteHead) {
|
|
await refreshRemoteTrackingBaseRef(repoRoot, remoteHead, resolveGitAuth);
|
|
if (await resolveBaseRefSha(repoRoot, remoteHead)) return remoteHead;
|
|
}
|
|
} catch {
|
|
// Not set — fall through to heuristic
|
|
}
|
|
|
|
// Fallback: check for common default branch names on the remote
|
|
for (const candidate of ["origin/master", "origin/main", "main", "master"]) {
|
|
try {
|
|
await refreshRemoteTrackingBaseRef(repoRoot, candidate, resolveGitAuth);
|
|
await runGit(["rev-parse", "--verify", `${candidate}^{commit}`], repoRoot);
|
|
return candidate;
|
|
} catch {
|
|
// Not found — try next
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function directoryExists(value: string) {
|
|
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false);
|
|
}
|
|
|
|
async function resolvePathForWorktreeComparison(value: string): Promise<string> {
|
|
const resolved = path.resolve(value);
|
|
return fs.realpath(resolved).then((realPath) => path.resolve(realPath)).catch(() => resolved);
|
|
}
|
|
|
|
async function listLinkedGitWorktreePaths(repoRoot: string): Promise<
|
|
{ paths: Set<string>; diagnostic?: never } | { paths?: never; diagnostic: ManagedGitInspectionDiagnostic }
|
|
> {
|
|
let proc: Awaited<ReturnType<typeof executeProcess>>;
|
|
try {
|
|
proc = await executeProcess({ command: "git", args: ["worktree", "list", "--porcelain"], cwd: repoRoot });
|
|
} catch (error) {
|
|
return { diagnostic: readManagedGitInspectionDiagnostic({
|
|
command: "worktree_list", failure: "spawn_failed", errorCode: (error as NodeJS.ErrnoException)?.code,
|
|
})! };
|
|
}
|
|
if (proc.code !== 0) {
|
|
return { diagnostic: readManagedGitInspectionDiagnostic({
|
|
command: "worktree_list", failure: "nonzero_exit", exitCode: proc.code,
|
|
})! };
|
|
}
|
|
if (proc.stdoutTruncated) {
|
|
return { diagnostic: { command: "worktree_list", failure: "output_truncated" } };
|
|
}
|
|
const paths = new Set<string>();
|
|
for (const line of proc.stdout.split("\n")) {
|
|
if (!line.startsWith("worktree ")) continue;
|
|
const worktree = line.slice("worktree ".length).trim();
|
|
if (!worktree) continue;
|
|
paths.add(await resolvePathForWorktreeComparison(worktree));
|
|
}
|
|
return { paths };
|
|
}
|
|
|
|
export async function inspectManagedGitWorktreeBranch(input: {
|
|
worktreePath: string;
|
|
expectedBranchName: string | null | undefined;
|
|
repoRoot?: string | null;
|
|
}): Promise<ManagedGitWorktreeBranchInspection> {
|
|
const worktreePath = await resolvePathForWorktreeComparison(input.worktreePath);
|
|
const expectedBranchName = asString(input.expectedBranchName, "").trim() || null;
|
|
const base = {
|
|
worktreePath,
|
|
expectedBranchName,
|
|
actualBranchName: null,
|
|
};
|
|
|
|
if (!await directoryExists(worktreePath)) {
|
|
return {
|
|
...base,
|
|
valid: false,
|
|
reason: `worktree path "${worktreePath}" does not exist`,
|
|
reasonCode: "missing_worktree",
|
|
repoRoot: input.repoRoot ? path.resolve(input.repoRoot) : null,
|
|
};
|
|
}
|
|
|
|
const repoRoot = input.repoRoot
|
|
? path.resolve(input.repoRoot)
|
|
: await resolveGitOwnerRepoRoot(worktreePath).catch(() => null);
|
|
if (!repoRoot) {
|
|
return {
|
|
...base,
|
|
valid: false,
|
|
reason: "path is not a git checkout",
|
|
reasonCode: "not_a_git_checkout",
|
|
repoRoot: null,
|
|
};
|
|
}
|
|
|
|
const listedWorktrees = await listLinkedGitWorktreePaths(repoRoot);
|
|
if (listedWorktrees.diagnostic) {
|
|
return {
|
|
...base,
|
|
valid: false,
|
|
reason: "could not inspect the complete git worktree registration list",
|
|
reasonCode: "git_inspection_failed",
|
|
repoRoot,
|
|
inspectionDiagnostic: listedWorktrees.diagnostic,
|
|
};
|
|
}
|
|
if (!listedWorktrees.paths.has(worktreePath)) {
|
|
return {
|
|
...base,
|
|
valid: false,
|
|
reason: "path is not registered in `git worktree list`",
|
|
reasonCode: "not_registered",
|
|
repoRoot,
|
|
};
|
|
}
|
|
|
|
const worktreeTopLevel = await runGit(["rev-parse", "--show-toplevel"], worktreePath).catch(() => null);
|
|
if (!worktreeTopLevel || path.resolve(worktreeTopLevel) !== worktreePath) {
|
|
return {
|
|
...base,
|
|
valid: false,
|
|
reason: "git resolves this path to a different repository root",
|
|
reasonCode: "wrong_repository_root",
|
|
repoRoot,
|
|
};
|
|
}
|
|
|
|
const actualBranchName = await runGit(
|
|
["symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
worktreePath,
|
|
).catch(() => null);
|
|
if (expectedBranchName && actualBranchName !== expectedBranchName) {
|
|
return {
|
|
...base,
|
|
valid: false,
|
|
reason: `worktree HEAD is on "${actualBranchName ?? "<detached>"}" instead of "${expectedBranchName}"`,
|
|
reasonCode: "branch_mismatch",
|
|
repoRoot,
|
|
actualBranchName,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
valid: true,
|
|
reason: null,
|
|
reasonCode: null,
|
|
repoRoot,
|
|
actualBranchName,
|
|
};
|
|
}
|
|
|
|
async function validateLinkedGitWorktree(input: {
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
expectedBranchName: string | null;
|
|
}): Promise<
|
|
| { valid: true }
|
|
| {
|
|
valid: false;
|
|
reason: string;
|
|
reasonCode: Exclude<ManagedGitWorktreeBranchInspection["reasonCode"], null>;
|
|
actualBranchName?: string | null;
|
|
inspectionDiagnostic?: ManagedGitInspectionDiagnostic;
|
|
}
|
|
> {
|
|
const inspection = await inspectManagedGitWorktreeBranch({
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranchName: input.expectedBranchName,
|
|
});
|
|
return inspection.valid
|
|
? { valid: true }
|
|
: {
|
|
valid: false,
|
|
reason: inspection.reason ?? "unknown git worktree mismatch",
|
|
reasonCode: inspection.reasonCode ?? "not_a_git_checkout",
|
|
actualBranchName: inspection.actualBranchName,
|
|
...(inspection.inspectionDiagnostic ? { inspectionDiagnostic: inspection.inspectionDiagnostic } : {}),
|
|
};
|
|
}
|
|
|
|
export function formatManagedGitWorktreeBranchInspection(input: ManagedGitWorktreeBranchInspection) {
|
|
return {
|
|
valid: input.valid,
|
|
reason: input.reason,
|
|
reasonCode: input.reasonCode,
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
expectedBranchName: input.expectedBranchName,
|
|
actualBranchName: input.actualBranchName,
|
|
...(input.inspectionDiagnostic ? { inspectionDiagnostic: input.inspectionDiagnostic } : {}),
|
|
};
|
|
}
|
|
|
|
function buildWorkspaceCommandEnv(input: {
|
|
base: ExecutionWorkspaceInput;
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
branchName: string;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
created: boolean;
|
|
}) {
|
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
env.PAPERCLIP_WORKSPACE_CWD = input.worktreePath;
|
|
env.PAPERCLIP_WORKSPACE_PATH = input.worktreePath;
|
|
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = input.worktreePath;
|
|
env.PAPERCLIP_WORKSPACE_BRANCH = input.branchName;
|
|
env.PAPERCLIP_WORKSPACE_BASE_CWD = input.base.baseCwd;
|
|
env.PAPERCLIP_WORKSPACE_REPO_ROOT = input.repoRoot;
|
|
env.PAPERCLIP_WORKSPACE_SOURCE = input.base.source;
|
|
env.PAPERCLIP_WORKSPACE_REPO_REF = input.base.repoRef ?? "";
|
|
env.PAPERCLIP_WORKSPACE_REPO_URL = input.base.repoUrl ?? "";
|
|
env.PAPERCLIP_WORKSPACE_CREATED = input.created ? "true" : "false";
|
|
env.PAPERCLIP_PROJECT_ID = input.base.projectId ?? "";
|
|
env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.base.workspaceId ?? "";
|
|
env.PAPERCLIP_AGENT_ID = input.agent.id ?? "";
|
|
env.PAPERCLIP_AGENT_NAME = input.agent.name;
|
|
env.PAPERCLIP_COMPANY_ID = input.agent.companyId;
|
|
env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? "";
|
|
env.PAPERCLIP_ISSUE_IDENTIFIER = input.issue?.identifier ?? "";
|
|
env.PAPERCLIP_ISSUE_TITLE = input.issue?.title ?? "";
|
|
env.PAPERCLIP_ISSUE_WORK_MODE = input.issue?.workMode ?? "";
|
|
return env;
|
|
}
|
|
|
|
function quoteShellArg(value: string) {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
const BUILTIN_WORKSPACE_PROVISION_COMMAND = "bash ./scripts/provision-worktree.sh";
|
|
|
|
function resolveWorkspaceProvisionCommand(
|
|
strategy: Record<string, unknown>,
|
|
repoRoot: string,
|
|
) {
|
|
const configuredCommand = asString(strategy.provisionCommand, "").trim();
|
|
if (configuredCommand) return configuredCommand;
|
|
|
|
return existsSync(path.join(repoRoot, "scripts", "provision-worktree.sh"))
|
|
? BUILTIN_WORKSPACE_PROVISION_COMMAND
|
|
: "";
|
|
}
|
|
|
|
function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) {
|
|
const patterns = [
|
|
/^(?<prefix>(?:bash|sh|zsh)\s+)(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
|
|
/^(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
const match = command.match(pattern);
|
|
if (!match?.groups) continue;
|
|
|
|
const relativePath = match.groups.relative;
|
|
const repoManagedPath = path.join(repoRoot, relativePath.slice(2));
|
|
if (!existsSync(repoManagedPath)) continue;
|
|
|
|
const prefix = match.groups.prefix ?? "";
|
|
const suffix = match.groups.suffix ?? "";
|
|
return `${prefix}${quoteShellArg(repoManagedPath)}${suffix}`;
|
|
}
|
|
|
|
return command;
|
|
}
|
|
|
|
async function runWorkspaceCommand(input: {
|
|
command: string;
|
|
resolvedCommand?: string;
|
|
cwd: string;
|
|
env: NodeJS.ProcessEnv;
|
|
label: string;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
}) {
|
|
const shell = resolveShell();
|
|
const proc = await executeProcess({
|
|
command: shell,
|
|
args: ["-c", input.resolvedCommand ?? input.command],
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
});
|
|
if (proc.stdout && input.onLog) await input.onLog("stdout", `[runtime-provision] ${proc.stdout}`);
|
|
if (proc.stderr && input.onLog) await input.onLog("stderr", `[runtime-provision] ${proc.stderr}`);
|
|
if (proc.code === 0) return;
|
|
|
|
const details = [proc.stderr.trim(), proc.stdout.trim()].filter(Boolean).join("\n");
|
|
throw new Error(
|
|
details.length > 0
|
|
? `${input.label} failed: ${details}`
|
|
: `${input.label} failed with exit code ${proc.code ?? -1}`,
|
|
);
|
|
}
|
|
|
|
async function recordGitOperation(
|
|
recorder: WorkspaceOperationRecorder | null | undefined,
|
|
input: {
|
|
phase: WorkspaceOperationPhase;
|
|
args: string[];
|
|
cwd: string;
|
|
metadata?: Record<string, unknown> | null;
|
|
successMessage?: string | null;
|
|
failureLabel?: string | null;
|
|
},
|
|
): Promise<string> {
|
|
if (!recorder) {
|
|
return runGit(input.args, input.cwd);
|
|
}
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let code: number | null = null;
|
|
await recorder.recordOperation({
|
|
phase: input.phase,
|
|
command: formatCommandForDisplay("git", input.args),
|
|
cwd: input.cwd,
|
|
metadata: input.metadata ?? null,
|
|
run: async () => {
|
|
const result = await executeProcess({
|
|
command: "git",
|
|
args: input.args,
|
|
cwd: input.cwd,
|
|
});
|
|
stdout = result.stdout;
|
|
stderr = result.stderr;
|
|
code = result.code;
|
|
return {
|
|
status: result.code === 0 ? "succeeded" : "failed",
|
|
exitCode: result.code,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
system: result.code === 0 ? input.successMessage ?? null : null,
|
|
metadata:
|
|
result.stdoutTruncated || result.stderrTruncated
|
|
? {
|
|
stdoutTruncated: result.stdoutTruncated,
|
|
stderrTruncated: result.stderrTruncated,
|
|
stdoutBytes: result.stdoutBytes,
|
|
stderrBytes: result.stderrBytes,
|
|
}
|
|
: null,
|
|
};
|
|
},
|
|
});
|
|
|
|
if (code !== 0) {
|
|
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
throw new Error(
|
|
details.length > 0
|
|
? `${input.failureLabel ?? `git ${input.args.join(" ")}`} failed: ${details}`
|
|
: `${input.failureLabel ?? `git ${input.args.join(" ")}`} failed with exit code ${code ?? -1}`,
|
|
);
|
|
}
|
|
return stdout.trim();
|
|
}
|
|
|
|
async function recordWorkspaceCommandOperation(
|
|
recorder: WorkspaceOperationRecorder | null | undefined,
|
|
input: {
|
|
phase: "workspace_provision" | "workspace_seed" | "workspace_runtime_provision" | "workspace_teardown";
|
|
command: string;
|
|
resolvedCommand?: string;
|
|
cwd: string;
|
|
env: NodeJS.ProcessEnv;
|
|
label: string;
|
|
metadata?: Record<string, unknown> | null;
|
|
successMessage?: string | null;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
},
|
|
) {
|
|
if (!recorder) {
|
|
await runWorkspaceCommand(input);
|
|
return null;
|
|
}
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let code: number | null = null;
|
|
const operation = await recorder.recordOperation({
|
|
phase: input.phase,
|
|
command: input.command,
|
|
cwd: input.cwd,
|
|
metadata: input.metadata ?? null,
|
|
run: async () => {
|
|
const shell = resolveShell();
|
|
const result = await executeProcess({
|
|
command: shell,
|
|
args: ["-c", input.resolvedCommand ?? input.command],
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
});
|
|
const seedEvidence = input.phase === "workspace_seed"
|
|
? readWorkspaceSeedOperationEvidence(input.cwd)
|
|
: null;
|
|
stdout = result.stdout;
|
|
stderr = [result.stderr, seedEvidence?.error].filter(Boolean).join("\n");
|
|
code = result.code === 0 && seedEvidence && !seedEvidence.verified ? 1 : result.code;
|
|
if (result.stdout && input.onLog) await input.onLog("stdout", `[runtime-provision] ${result.stdout}`);
|
|
if (stderr && input.onLog) await input.onLog("stderr", `[runtime-provision] ${stderr}`);
|
|
const truncationMetadata = result.stdoutTruncated || result.stderrTruncated
|
|
? {
|
|
stdoutTruncated: result.stdoutTruncated,
|
|
stderrTruncated: result.stderrTruncated,
|
|
stdoutBytes: result.stdoutBytes,
|
|
stderrBytes: result.stderrBytes,
|
|
}
|
|
: null;
|
|
return {
|
|
status: code === 0 ? "succeeded" : "failed",
|
|
exitCode: code,
|
|
stdout: result.stdout,
|
|
stderr,
|
|
system: code === 0 ? input.successMessage ?? null : null,
|
|
metadata: seedEvidence
|
|
? { ...seedEvidence.metadata, ...(truncationMetadata ?? {}) }
|
|
: truncationMetadata,
|
|
};
|
|
},
|
|
});
|
|
|
|
if (code === 0) return operation;
|
|
|
|
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
throw new Error(
|
|
details.length > 0
|
|
? `${input.label} failed: ${details}`
|
|
: `${input.label} failed with exit code ${code ?? -1}`,
|
|
);
|
|
}
|
|
|
|
async function provisionExecutionWorktree(input: {
|
|
strategy: Record<string, unknown>;
|
|
base: ExecutionWorkspaceInput;
|
|
repoRoot: string;
|
|
worktreePath: string;
|
|
branchName: string;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
created: boolean;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
}) {
|
|
const provisionCommand = resolveWorkspaceProvisionCommand(input.strategy, input.repoRoot);
|
|
if (!provisionCommand) return;
|
|
const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
|
|
|
|
await recordWorkspaceCommandOperation(input.recorder, {
|
|
phase: "workspace_provision",
|
|
command: provisionCommand,
|
|
resolvedCommand: resolvedProvisionCommand,
|
|
cwd: input.worktreePath,
|
|
env: buildWorkspaceCommandEnv({
|
|
base: input.base,
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
branchName: input.branchName,
|
|
issue: input.issue,
|
|
agent: input.agent,
|
|
created: input.created,
|
|
}),
|
|
label: `Execution workspace provision command "${provisionCommand}"`,
|
|
metadata: {
|
|
repoRoot: input.repoRoot,
|
|
worktreePath: input.worktreePath,
|
|
branchName: input.branchName,
|
|
created: input.created,
|
|
resolvedCommand: resolvedProvisionCommand === provisionCommand ? null : resolvedProvisionCommand,
|
|
},
|
|
successMessage: `Provisioned workspace at ${input.worktreePath}\n`,
|
|
});
|
|
}
|
|
|
|
function buildExecutionWorkspaceCleanupEnv(input: {
|
|
workspace: {
|
|
cwd: string | null;
|
|
providerRef: string | null;
|
|
branchName: string | null;
|
|
repoUrl: string | null;
|
|
baseRef: string | null;
|
|
projectId: string | null;
|
|
projectWorkspaceId: string | null;
|
|
sourceIssueId: string | null;
|
|
};
|
|
projectWorkspaceCwd?: string | null;
|
|
}) {
|
|
const env: NodeJS.ProcessEnv = sanitizeRuntimeServiceBaseEnv(process.env);
|
|
env.PAPERCLIP_WORKSPACE_CWD = input.workspace.cwd ?? "";
|
|
env.PAPERCLIP_WORKSPACE_PATH = input.workspace.cwd ?? "";
|
|
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH =
|
|
input.workspace.providerRef ?? input.workspace.cwd ?? "";
|
|
env.PAPERCLIP_WORKSPACE_BRANCH = input.workspace.branchName ?? "";
|
|
env.PAPERCLIP_WORKSPACE_BASE_CWD = input.projectWorkspaceCwd ?? "";
|
|
env.PAPERCLIP_WORKSPACE_REPO_ROOT = input.projectWorkspaceCwd ?? "";
|
|
env.PAPERCLIP_WORKSPACE_REPO_URL = input.workspace.repoUrl ?? "";
|
|
env.PAPERCLIP_WORKSPACE_REPO_REF = input.workspace.baseRef ?? "";
|
|
env.PAPERCLIP_PROJECT_ID = input.workspace.projectId ?? "";
|
|
env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.workspace.projectWorkspaceId ?? "";
|
|
env.PAPERCLIP_ISSUE_ID = input.workspace.sourceIssueId ?? "";
|
|
return env;
|
|
}
|
|
|
|
async function resolveGitRepoRootForWorkspaceCleanup(
|
|
worktreePath: string,
|
|
projectWorkspaceCwd: string | null,
|
|
): Promise<string | null> {
|
|
if (projectWorkspaceCwd) {
|
|
const resolvedProjectWorkspaceCwd = path.resolve(projectWorkspaceCwd);
|
|
const gitDir = await runGit(["rev-parse", "--git-common-dir"], resolvedProjectWorkspaceCwd)
|
|
.catch(() => null);
|
|
if (gitDir) {
|
|
const resolvedGitDir = path.resolve(resolvedProjectWorkspaceCwd, gitDir);
|
|
return path.dirname(resolvedGitDir);
|
|
}
|
|
}
|
|
|
|
const gitDir = await runGit(["rev-parse", "--git-common-dir"], worktreePath).catch(() => null);
|
|
if (!gitDir) return null;
|
|
const resolvedGitDir = path.resolve(worktreePath, gitDir);
|
|
return path.dirname(resolvedGitDir);
|
|
}
|
|
|
|
export async function realizeExecutionWorkspace(input: {
|
|
db?: Db | null;
|
|
base: ExecutionWorkspaceInput;
|
|
config: Record<string, unknown>;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
recordedBranchOwnership?: {
|
|
branchName: string;
|
|
createdByRuntime: boolean;
|
|
} | null;
|
|
heartbeatRunId?: string | null;
|
|
enableWorkspaceBranchReconcileForward?: boolean;
|
|
enableWorkspaceDirtyQuarantineRepair?: boolean;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
resolveGitAuth?: GitRemoteAuthProvider | null;
|
|
}): Promise<RealizedExecutionWorkspace> {
|
|
const rawStrategy = parseObject(input.config.workspaceStrategy);
|
|
const strategyType = asString(rawStrategy.type, "project_primary");
|
|
const requestedExistingBranch = asString(rawStrategy.existingBranch, "").trim();
|
|
if (strategyType !== "git_worktree") {
|
|
if (requestedExistingBranch) {
|
|
throw new WorkspaceRuntimeValidationFailure(
|
|
`Workspace strategy pins existing branch "${requestedExistingBranch}" but has type "${strategyType}"; an exact-branch workspace requires strategy type "git_worktree". Set workspaceStrategy.type to "git_worktree" or remove existingBranch.`,
|
|
{
|
|
workspaceValidation: {
|
|
reason: "existing_branch_requires_git_worktree",
|
|
requestedExistingBranch,
|
|
strategyType,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
return {
|
|
...input.base,
|
|
strategy: "project_primary",
|
|
cwd: input.base.baseCwd,
|
|
branchName: null,
|
|
worktreePath: null,
|
|
warnings: [],
|
|
created: false,
|
|
branchCreatedByRuntime: false,
|
|
baseRefSha: null,
|
|
};
|
|
}
|
|
|
|
const repoRoot = await resolveGitOwnerRepoRoot(input.base.baseCwd);
|
|
let branchName: string;
|
|
if (requestedExistingBranch) {
|
|
// Exact-branch mode: attach the requested pre-existing branch verbatim.
|
|
// The branch must already exist; realization never creates, renames, or
|
|
// resets it, and any mismatch below fails closed instead of falling back
|
|
// to a derived branch or the shared checkout.
|
|
const existingBranchSha = await runGit(
|
|
["rev-parse", "--verify", "--quiet", `refs/heads/${requestedExistingBranch}`],
|
|
repoRoot,
|
|
).catch(() => null);
|
|
if (!existingBranchSha) {
|
|
throw new WorkspaceRuntimeValidationFailure(
|
|
`Workspace strategy pins existing branch "${requestedExistingBranch}", but no local branch with that name exists in "${repoRoot}". Create or fetch the branch first, or remove workspaceStrategy.existingBranch; exact-branch realization never creates a branch.`,
|
|
{
|
|
workspaceValidation: {
|
|
reason: "existing_branch_not_found",
|
|
requestedExistingBranch,
|
|
repoRoot,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
branchName = requestedExistingBranch;
|
|
} else {
|
|
const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
|
|
const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
|
|
issue: input.issue,
|
|
agent: input.agent,
|
|
projectId: input.base.projectId,
|
|
repoRef: input.base.repoRef,
|
|
});
|
|
branchName = sanitizeBranchName(renderedBranch);
|
|
}
|
|
const configuredParentDir = asString(rawStrategy.worktreeParentDir, "");
|
|
const worktreeParentDir = configuredParentDir
|
|
? resolveConfiguredPath(configuredParentDir, repoRoot)
|
|
: path.join(repoRoot, ".paperclip", "worktrees");
|
|
const worktreePath = path.join(worktreeParentDir, branchName);
|
|
if (path.relative(worktreeParentDir, worktreePath).startsWith("..")) {
|
|
throw new WorkspaceRuntimeValidationFailure(
|
|
`Workspace branch "${branchName}" resolves to a worktree path outside the managed worktree parent directory "${worktreeParentDir}".`,
|
|
{
|
|
workspaceValidation: {
|
|
reason: "worktree_path_escapes_parent_dir",
|
|
branchName,
|
|
worktreeParentDir,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
let pendingForwardBranchReconcile: PendingForwardBranchReconcile | null = null;
|
|
const configuredBaseRef = typeof rawStrategy.baseRef === "string" && rawStrategy.baseRef.length > 0
|
|
? rawStrategy.baseRef
|
|
: input.base.repoRef ?? null;
|
|
const baseRefResolution = await resolveAuthoritativeBaseRef(repoRoot, configuredBaseRef, input.resolveGitAuth);
|
|
// Keep a usable base ref for the reuse and drift paths even when the ref is
|
|
// unresolved: those paths tolerate a null base-ref SHA and never run
|
|
// `git worktree add -b <branch> <baseRef>`. Only the fresh-create path below
|
|
// stops on an unresolved ref. `baseRefAlreadyRefreshed` is true for the
|
|
// unresolved case because the resolver already attempted the fetch.
|
|
const baseRef = baseRefResolution.resolved ? baseRefResolution.baseRef : baseRefResolution.requestedRef;
|
|
const baseRefResolutionWarnings = baseRefResolution.warnings;
|
|
const baseRefAlreadyRefreshed = baseRefResolution.resolved ? baseRefResolution.refreshed : true;
|
|
const baseRefreshWarnings = [
|
|
...baseRefResolutionWarnings,
|
|
...(baseRefAlreadyRefreshed ? [] : await refreshRemoteTrackingBaseRef(repoRoot, baseRef, input.resolveGitAuth)),
|
|
];
|
|
const currentBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
|
|
|
await fs.mkdir(worktreeParentDir, { recursive: true });
|
|
|
|
async function reuseExistingWorktree(reusablePath: string, effectiveBranchName = branchName, extraWarnings: string[] = []) {
|
|
// An exact-branch attach must never move the requested branch, so skip
|
|
// the unstarted-worktree fast-forward that template-derived reuse gets.
|
|
const refresh = currentBaseRefSha && !requestedExistingBranch
|
|
? await refreshUnstartedWorktreeToBase({
|
|
repoRoot,
|
|
worktreePath: reusablePath,
|
|
branchName: effectiveBranchName,
|
|
baseRef,
|
|
currentBaseRefSha,
|
|
recorder: input.recorder ?? null,
|
|
})
|
|
: { refreshed: false, baseRefSha: null };
|
|
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
|
repoRoot,
|
|
worktreePath: reusablePath,
|
|
branchName,
|
|
baseRef,
|
|
recordedBaseRefSha: null,
|
|
skipRefresh: true,
|
|
});
|
|
if (input.recorder) {
|
|
await input.recorder.recordOperation({
|
|
phase: "worktree_prepare",
|
|
cwd: repoRoot,
|
|
metadata: {
|
|
repoRoot,
|
|
worktreePath: reusablePath,
|
|
branchName: effectiveBranchName,
|
|
baseRef,
|
|
currentBaseRefSha: baseDrift.currentBaseRefSha,
|
|
branchBaseRefSha: baseDrift.branchBaseRefSha,
|
|
created: false,
|
|
reused: true,
|
|
},
|
|
run: async () => ({
|
|
status: "succeeded",
|
|
exitCode: 0,
|
|
system: `Reused existing git worktree at ${reusablePath}\n`,
|
|
}),
|
|
});
|
|
}
|
|
await provisionExecutionWorktree({
|
|
strategy: rawStrategy,
|
|
base: input.base,
|
|
repoRoot,
|
|
worktreePath: reusablePath,
|
|
branchName: effectiveBranchName,
|
|
issue: input.issue,
|
|
agent: input.agent,
|
|
created: false,
|
|
recorder: input.recorder ?? null,
|
|
});
|
|
return {
|
|
...input.base,
|
|
repoRef: baseRef,
|
|
strategy: "git_worktree" as const,
|
|
cwd: reusablePath,
|
|
branchName: effectiveBranchName,
|
|
worktreePath: reusablePath,
|
|
warnings: [...extraWarnings, ...baseRefreshWarnings, ...baseDrift.warnings],
|
|
created: false,
|
|
// A fresh realization may still land on the worktree recorded by a
|
|
// previous heartbeat. Preserve that branch's ownership only when the
|
|
// recorded branch matches the checkout being reused. Exact-branch mode
|
|
// remains operator-owned by contract; every mismatch likewise fails
|
|
// safe and leaves the branch behind during terminal cleanup.
|
|
branchCreatedByRuntime:
|
|
!requestedExistingBranch
|
|
&& input.recordedBranchOwnership?.branchName === effectiveBranchName
|
|
&& input.recordedBranchOwnership.createdByRuntime === true,
|
|
baseRefSha: refresh.baseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha,
|
|
pendingForwardBranchReconcile,
|
|
};
|
|
}
|
|
|
|
async function validateReusableWorktree(reusablePath: string) {
|
|
const validation = await validateLinkedGitWorktree({
|
|
repoRoot,
|
|
worktreePath: reusablePath,
|
|
expectedBranchName: branchName,
|
|
}).catch(() => null);
|
|
if (validation && !validation.valid && validation.reasonCode === "branch_mismatch") {
|
|
if (requestedExistingBranch) {
|
|
// Exact-branch mode never reconciles a mismatched checkout onto
|
|
// another branch; the caller fails closed with the mismatch reason.
|
|
return { validation, branchName, warnings: [] };
|
|
}
|
|
const coherence = await ensureGitWorktreeBranchCoherent({
|
|
db: input.db ?? null,
|
|
repoRoot,
|
|
worktreePath: reusablePath,
|
|
expectedBranchName: branchName,
|
|
actualBranchName: validation.actualBranchName ?? null,
|
|
sourceIssue: input.issue,
|
|
executionWorkspaceId: null,
|
|
heartbeatRunId: input.heartbeatRunId ?? null,
|
|
enableWorkspaceBranchReconcileForward: input.enableWorkspaceBranchReconcileForward === true,
|
|
enableWorkspaceDirtyQuarantineRepair: input.enableWorkspaceDirtyQuarantineRepair === true,
|
|
reconcileOperationPhase: "worktree_prepare",
|
|
recorder: input.recorder ?? null,
|
|
});
|
|
const effectiveBranchName = coherence.branchName ?? branchName;
|
|
if (coherence.reconciledForward) {
|
|
branchName = effectiveBranchName;
|
|
pendingForwardBranchReconcile = coherence.pendingForwardBranchReconcile ?? null;
|
|
}
|
|
const nextValidation = await validateLinkedGitWorktree({
|
|
repoRoot,
|
|
worktreePath: reusablePath,
|
|
expectedBranchName: effectiveBranchName,
|
|
}).catch(() => null);
|
|
return {
|
|
validation: nextValidation,
|
|
branchName: effectiveBranchName,
|
|
warnings: coherence.warnings,
|
|
};
|
|
}
|
|
return { validation, branchName, warnings: [] };
|
|
}
|
|
|
|
const existingWorktree = await directoryExists(worktreePath);
|
|
if (existingWorktree) {
|
|
const reusable = await validateReusableWorktree(worktreePath);
|
|
if (reusable.validation?.valid) {
|
|
return await reuseExistingWorktree(worktreePath, reusable.branchName, reusable.warnings);
|
|
}
|
|
const validation = reusable.validation;
|
|
const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
|
|
if (requestedExistingBranch) {
|
|
throw new WorkspaceRuntimeValidationFailure(
|
|
`Workspace strategy pins existing branch "${requestedExistingBranch}", but the worktree path "${worktreePath}" already exists and is not a reusable checkout of that branch${reason}. Repair or remove that worktree, then retry; exact-branch realization never reconciles it onto another branch.`,
|
|
{
|
|
workspaceValidation: {
|
|
reason: "existing_branch_worktree_not_reusable",
|
|
reasonCode: validation && !validation.valid ? validation.reasonCode : null,
|
|
requestedExistingBranch,
|
|
worktreePath,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a reusable git worktree${reason}.`);
|
|
}
|
|
|
|
const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
|
|
if (registeredBranchWorktree) {
|
|
const reusable = await validateReusableWorktree(registeredBranchWorktree);
|
|
if (reusable.validation?.valid) {
|
|
return await reuseExistingWorktree(registeredBranchWorktree, reusable.branchName, reusable.warnings);
|
|
}
|
|
const validation = reusable.validation;
|
|
const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
|
|
if (requestedExistingBranch) {
|
|
throw new WorkspaceRuntimeValidationFailure(
|
|
`Workspace strategy pins existing branch "${requestedExistingBranch}", which is already checked out at "${registeredBranchWorktree}", but that worktree is not reusable${reason}. Repair or remove that worktree, then retry.`,
|
|
{
|
|
workspaceValidation: {
|
|
reason: "existing_branch_worktree_not_reusable",
|
|
reasonCode: validation && !validation.valid ? validation.reasonCode : null,
|
|
requestedExistingBranch,
|
|
worktreePath: registeredBranchWorktree,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
throw new Error(`Registered worktree for branch "${branchName}" at "${registeredBranchWorktree}" is not reusable${reason}.`);
|
|
}
|
|
|
|
if (requestedExistingBranch) {
|
|
try {
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_prepare",
|
|
args: ["worktree", "add", worktreePath, branchName],
|
|
cwd: repoRoot,
|
|
metadata: {
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
baseRef,
|
|
baseRefSha: currentBaseRefSha,
|
|
created: false,
|
|
attachedExistingBranch: true,
|
|
},
|
|
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
|
|
failureLabel: `git worktree add ${worktreePath}`,
|
|
});
|
|
} catch (attachError) {
|
|
const message = attachError instanceof Error ? attachError.message : String(attachError);
|
|
throw new WorkspaceRuntimeValidationFailure(
|
|
`Could not attach existing branch "${requestedExistingBranch}" as a git worktree at "${worktreePath}": ${message}`,
|
|
{
|
|
workspaceValidation: {
|
|
reason: "existing_branch_attach_failed",
|
|
requestedExistingBranch,
|
|
worktreePath,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
await provisionExecutionWorktree({
|
|
strategy: rawStrategy,
|
|
base: input.base,
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
issue: input.issue,
|
|
agent: input.agent,
|
|
created: true,
|
|
recorder: input.recorder ?? null,
|
|
});
|
|
return {
|
|
...input.base,
|
|
repoRef: baseRef,
|
|
strategy: "git_worktree",
|
|
cwd: worktreePath,
|
|
branchName,
|
|
worktreePath,
|
|
warnings: baseRefreshWarnings,
|
|
// The worktree is new, but the pinned branch pre-existed: it stays
|
|
// operator-owned so terminal cleanup never deletes it.
|
|
created: true,
|
|
branchCreatedByRuntime: false,
|
|
baseRefSha: currentBaseRefSha,
|
|
};
|
|
}
|
|
|
|
// No reusable worktree exists, so a fresh `git worktree add -b <branch> <baseRef>`
|
|
// must run next. An unresolved base ref would make git fail with
|
|
// `fatal: invalid reference`. Stop here instead and raise a pre-dispatch
|
|
// configuration failure that the setup catch routes to a human owner.
|
|
if (!baseRefResolution.resolved) {
|
|
const error = new UnresolvedWorkspaceBaseRefError({
|
|
requestedRef: baseRefResolution.requestedRef,
|
|
recoveryIdentityRef: baseRefResolution.recoveryIdentityRef,
|
|
attemptedRefs: baseRefResolution.attemptedRefs,
|
|
fetchError: baseRefResolution.fetchError,
|
|
defaultBranch: await readAdvertisedDefaultBranch(repoRoot, input.resolveGitAuth),
|
|
});
|
|
unresolvedBaseRefDiagnostics.set(error, readWorkspaceBaseRefDiagnostic(baseRefResolution.diagnostic)!);
|
|
throw error;
|
|
}
|
|
|
|
let branchCreatedByRuntime = true;
|
|
try {
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_prepare",
|
|
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
|
|
cwd: repoRoot,
|
|
metadata: {
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
baseRef,
|
|
baseRefSha: currentBaseRefSha,
|
|
created: true,
|
|
},
|
|
successMessage: `Created git worktree at ${worktreePath}\n`,
|
|
failureLabel: `git worktree add ${worktreePath}`,
|
|
});
|
|
} catch (error) {
|
|
if (!gitErrorIncludes(error, "already exists")) {
|
|
throw error;
|
|
}
|
|
try {
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_prepare",
|
|
args: ["worktree", "add", worktreePath, branchName],
|
|
cwd: repoRoot,
|
|
metadata: {
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
baseRef,
|
|
baseRefSha: currentBaseRefSha,
|
|
created: false,
|
|
reusedExistingBranch: true,
|
|
},
|
|
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
|
|
failureLabel: `git worktree add ${worktreePath}`,
|
|
});
|
|
// The template rendered to a branch that already existed, so this
|
|
// attach did not create the branch and cleanup must not delete it.
|
|
branchCreatedByRuntime = false;
|
|
} catch (attachError) {
|
|
if (!gitErrorIncludes(attachError, "already checked out")) {
|
|
throw attachError;
|
|
}
|
|
const reusablePath = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
|
|
if (!reusablePath || !await isGitCheckout(reusablePath)) {
|
|
throw attachError;
|
|
}
|
|
return await reuseExistingWorktree(reusablePath);
|
|
}
|
|
}
|
|
await provisionExecutionWorktree({
|
|
strategy: rawStrategy,
|
|
base: input.base,
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
issue: input.issue,
|
|
agent: input.agent,
|
|
created: true,
|
|
recorder: input.recorder ?? null,
|
|
});
|
|
|
|
return {
|
|
...input.base,
|
|
repoRef: baseRef,
|
|
strategy: "git_worktree",
|
|
cwd: worktreePath,
|
|
branchName,
|
|
worktreePath,
|
|
warnings: baseRefreshWarnings,
|
|
created: true,
|
|
branchCreatedByRuntime,
|
|
baseRefSha: currentBaseRefSha,
|
|
};
|
|
}
|
|
|
|
export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
|
db?: Db | null;
|
|
base: ExecutionWorkspaceInput;
|
|
workspace: {
|
|
id?: string | null;
|
|
mode: string | null | undefined;
|
|
strategyType: string | null | undefined;
|
|
cwd: string | null | undefined;
|
|
providerRef: string | null | undefined;
|
|
projectId: string | null | undefined;
|
|
projectWorkspaceId: string | null | undefined;
|
|
repoUrl: string | null | undefined;
|
|
baseRef: string | null | undefined;
|
|
branchName: string | null | undefined;
|
|
metadata?: Record<string, unknown> | null;
|
|
config?: {
|
|
provisionCommand?: string | null;
|
|
runtimeProvisionCommand?: string | null;
|
|
} | null;
|
|
};
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
heartbeatRunId?: string | null;
|
|
enableWorkspaceBranchReconcileForward?: boolean;
|
|
enableWorkspaceDirtyQuarantineRepair?: boolean;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
resolveGitAuth?: GitRemoteAuthProvider | null;
|
|
}): Promise<RealizedExecutionWorkspace | null> {
|
|
const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim();
|
|
if (!cwd) return null;
|
|
|
|
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
|
|
const realized: RealizedExecutionWorkspace = {
|
|
baseCwd: input.base.baseCwd,
|
|
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
|
|
projectId: input.workspace.projectId ?? input.base.projectId,
|
|
workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
|
|
repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
|
|
repoRef: input.workspace.baseRef ?? input.base.repoRef,
|
|
additionalWorkspaces: input.base.additionalWorkspaces ?? [],
|
|
strategy,
|
|
cwd,
|
|
branchName: input.workspace.branchName ?? null,
|
|
worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
|
|
warnings: [],
|
|
created: false,
|
|
// Only the versioned ownership record introduced with branch-level
|
|
// ownership semantics can authorize branch recreation or deletion. Older
|
|
// createdByRuntime=true rows described worktree ownership, so trusting
|
|
// them here could recreate or later delete an operator-owned branch.
|
|
branchCreatedByRuntime: isRuntimeOwnedGitBranch(input.workspace.metadata),
|
|
baseRefSha: readRecordedBaseRefSha(input.workspace.metadata),
|
|
};
|
|
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
|
|
|
|
if (strategy !== "git_worktree") {
|
|
if (!await directoryExists(cwd)) {
|
|
return null;
|
|
}
|
|
return realized;
|
|
}
|
|
// Validate the base checkout before the git spawn. A missing or empty base
|
|
// path makes the "git" spawn fail with a raw "spawn git ENOENT" error. That
|
|
// error hides the real cause: the base project checkout is not on disk.
|
|
// Throw a clear cause first so a future failure names the missing checkout.
|
|
// Keep the persisted path exact. A directory name can start or end with a
|
|
// space, so a trim would change a valid checkout path.
|
|
const baseCwd = asString(input.base.baseCwd, "");
|
|
if (!baseCwd) {
|
|
throw new Error(
|
|
"Cannot rebuild the git worktree: the base project checkout path is empty.",
|
|
);
|
|
}
|
|
if (!await directoryExists(baseCwd)) {
|
|
throw new Error(
|
|
"Cannot rebuild the git worktree: the base project checkout directory does not exist.",
|
|
);
|
|
}
|
|
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], baseCwd);
|
|
const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata);
|
|
if (await directoryExists(cwd)) {
|
|
const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
|
|
const reuseWorktreePath = realized.worktreePath ?? cwd;
|
|
const repairWarnings: string[] = [];
|
|
if (await isGitCheckout(reuseWorktreePath) && realized.branchCreatedByRuntime) {
|
|
// Branch-coherence repair may check out another branch, adopt a forward
|
|
// branch, or move the recorded ref from a detached HEAD. Those repairs
|
|
// are valid only for a branch that this runtime created. An attached
|
|
// operator-owned branch must retain its exact identity and tip; the
|
|
// validation below rejects any mismatch without mutating Git state.
|
|
const coherence = await ensureGitWorktreeBranchCoherent({
|
|
db: input.db ?? null,
|
|
repoRoot,
|
|
worktreePath: reuseWorktreePath,
|
|
expectedBranchName: realized.branchName,
|
|
sourceIssue: input.issue,
|
|
executionWorkspaceId: input.workspace.id ?? null,
|
|
heartbeatRunId: input.heartbeatRunId ?? null,
|
|
enableWorkspaceBranchReconcileForward: input.enableWorkspaceBranchReconcileForward === true,
|
|
enableWorkspaceDirtyQuarantineRepair: input.enableWorkspaceDirtyQuarantineRepair === true,
|
|
persistForwardReconcile: false,
|
|
reconcileOperationPhase: "worktree_prepare",
|
|
recorder: input.recorder ?? null,
|
|
});
|
|
if (coherence.branchName) {
|
|
realized.branchName = coherence.branchName;
|
|
}
|
|
if (coherence.reconciledForward) {
|
|
realized.pendingForwardBranchReconcile = coherence.pendingForwardBranchReconcile ?? null;
|
|
}
|
|
repairWarnings.push(...coherence.warnings);
|
|
}
|
|
const validation = await validateLinkedGitWorktree({
|
|
repoRoot,
|
|
worktreePath: reuseWorktreePath,
|
|
expectedBranchName: realized.branchName,
|
|
});
|
|
if (!validation.valid) {
|
|
throw new WorkspaceRuntimeValidationFailure(
|
|
`Persisted git worktree "${reuseWorktreePath}" is not reusable (${validation.reason}).`,
|
|
{
|
|
workspaceValidation: {
|
|
reason: "git_worktree_not_reusable",
|
|
reasonCode: validation.reasonCode,
|
|
worktreePath: reuseWorktreePath,
|
|
executionWorkspaceId: input.workspace.id ?? null,
|
|
...(validation.inspectionDiagnostic ? { inspectionDiagnostic: validation.inspectionDiagnostic } : {}),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
const baseRefreshWarnings = reuseBaseRef
|
|
? await refreshRemoteTrackingBaseRef(repoRoot, reuseBaseRef, input.resolveGitAuth)
|
|
: [];
|
|
const currentBaseRefSha = reuseBaseRef ? await resolveBaseRefSha(repoRoot, reuseBaseRef) : null;
|
|
// An unstarted-worktree refresh can fast-forward the checked-out branch.
|
|
// Never run it for an attached operator-owned ref.
|
|
const refresh = realized.branchCreatedByRuntime && reuseBaseRef && currentBaseRefSha
|
|
? await refreshUnstartedWorktreeToBase({
|
|
repoRoot,
|
|
worktreePath: reuseWorktreePath,
|
|
branchName: realized.branchName,
|
|
baseRef: reuseBaseRef,
|
|
currentBaseRefSha,
|
|
recorder: input.recorder ?? null,
|
|
})
|
|
: { refreshed: false, baseRefSha: null };
|
|
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
|
repoRoot,
|
|
worktreePath: reuseWorktreePath,
|
|
branchName: realized.branchName,
|
|
baseRef: reuseBaseRef,
|
|
recordedBaseRefSha,
|
|
skipRefresh: true,
|
|
});
|
|
realized.warnings = [...repairWarnings, ...baseRefreshWarnings, ...baseDrift.warnings];
|
|
realized.baseRefSha = refresh.baseRefSha ?? recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
|
|
await provisionExecutionWorktree({
|
|
strategy: {
|
|
type: "git_worktree",
|
|
...(provisionCommand ? { provisionCommand } : {}),
|
|
},
|
|
base: input.base,
|
|
repoRoot,
|
|
worktreePath: realized.worktreePath ?? cwd,
|
|
branchName: realized.branchName ?? "",
|
|
issue: input.issue,
|
|
agent: input.agent,
|
|
created: false,
|
|
recorder: input.recorder ?? null,
|
|
});
|
|
return realized;
|
|
}
|
|
|
|
const worktreePath = realized.worktreePath ?? cwd;
|
|
const branchName = asString(input.workspace.branchName, "").trim();
|
|
if (!branchName) {
|
|
throw new Error(`Execution workspace "${cwd}" is missing and cannot be restored because no branch name is recorded.`);
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
|
await runGit(["worktree", "prune"], repoRoot).catch(() => {});
|
|
const restoreBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
|
|
const restoreRefreshWarnings = restoreBaseRef
|
|
? await refreshRemoteTrackingBaseRef(repoRoot, restoreBaseRef, input.resolveGitAuth)
|
|
: [];
|
|
const restoreCurrentBaseRefSha = restoreBaseRef ? await resolveBaseRefSha(repoRoot, restoreBaseRef) : null;
|
|
|
|
let created = false;
|
|
try {
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_prepare",
|
|
args: ["worktree", "add", worktreePath, branchName],
|
|
cwd: repoRoot,
|
|
metadata: {
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
|
|
currentBaseRefSha: restoreCurrentBaseRefSha,
|
|
created: false,
|
|
restored: true,
|
|
},
|
|
successMessage: `Reattached missing git worktree at ${worktreePath}\n`,
|
|
failureLabel: `git worktree add ${worktreePath}`,
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
!gitErrorIncludes(error, "invalid reference")
|
|
&& !gitErrorIncludes(error, "not a commit")
|
|
&& !gitErrorIncludes(error, "unknown revision")
|
|
) {
|
|
throw error;
|
|
}
|
|
if (!realized.branchCreatedByRuntime) {
|
|
throw new Error(
|
|
`Execution workspace "${worktreePath}" cannot be restored because its operator-owned branch "${branchName}" no longer exists.`,
|
|
);
|
|
}
|
|
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
|
|
const recreatedBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_prepare",
|
|
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
|
|
cwd: repoRoot,
|
|
metadata: {
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
baseRef,
|
|
baseRefSha: recreatedBaseRefSha,
|
|
created: true,
|
|
restored: true,
|
|
},
|
|
successMessage: `Recreated missing git worktree at ${worktreePath}\n`,
|
|
failureLabel: `git worktree add ${worktreePath}`,
|
|
});
|
|
created = true;
|
|
}
|
|
|
|
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
|
|
recordedBaseRefSha,
|
|
skipRefresh: true,
|
|
});
|
|
|
|
await provisionExecutionWorktree({
|
|
strategy: {
|
|
type: "git_worktree",
|
|
...(provisionCommand ? { provisionCommand } : {}),
|
|
},
|
|
base: input.base,
|
|
repoRoot,
|
|
worktreePath,
|
|
branchName,
|
|
issue: input.issue,
|
|
agent: input.agent,
|
|
created,
|
|
recorder: input.recorder ?? null,
|
|
});
|
|
|
|
return {
|
|
...realized,
|
|
cwd: worktreePath,
|
|
worktreePath,
|
|
warnings: [...restoreRefreshWarnings, ...baseDrift.warnings],
|
|
created,
|
|
branchCreatedByRuntime: realized.branchCreatedByRuntime || created,
|
|
baseRefSha:
|
|
recordedBaseRefSha
|
|
?? (created ? restoreCurrentBaseRefSha : baseDrift.branchBaseRefSha)
|
|
?? baseDrift.currentBaseRefSha,
|
|
};
|
|
}
|
|
|
|
export async function acquireGitWorktreeCleanupLock(worktreePath: string) {
|
|
const branchRef = await runGit(["symbolic-ref", "--quiet", "HEAD"], worktreePath).catch(() => null);
|
|
const rawLocks = await Promise.all([
|
|
runGit(["rev-parse", "--git-path", "index.lock"], worktreePath)
|
|
.then((lockPath) => ({ kind: "index" as const, lockPath })),
|
|
runGit(["rev-parse", "--git-path", "HEAD.lock"], worktreePath)
|
|
.then((lockPath) => ({ kind: "head" as const, lockPath })),
|
|
...(branchRef
|
|
? [runGit(["rev-parse", "--git-path", `${branchRef}.lock`], worktreePath)
|
|
.then((lockPath) => ({ kind: "branch" as const, lockPath }))]
|
|
: []),
|
|
]);
|
|
const locks = [...new Map(rawLocks.map(({ kind, lockPath }) => {
|
|
const resolvedLockPath = path.isAbsolute(lockPath)
|
|
? lockPath
|
|
: path.resolve(worktreePath, lockPath);
|
|
return [resolvedLockPath, { kind, lockPath: resolvedLockPath }];
|
|
})).values()];
|
|
const lockHandles: Array<{
|
|
handle: fs.FileHandle;
|
|
kind: "index" | "head" | "branch";
|
|
lockPath: string;
|
|
}> = [];
|
|
|
|
async function releaseLocks(kind?: "branch") {
|
|
for (let index = lockHandles.length - 1; index >= 0; index -= 1) {
|
|
const lock = lockHandles[index];
|
|
if (!lock || (kind && lock.kind !== kind)) continue;
|
|
lockHandles.splice(index, 1);
|
|
await lock.handle.close().catch(() => {});
|
|
await fs.rm(lock.lockPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (const lock of locks) {
|
|
lockHandles.push({
|
|
...lock,
|
|
handle: await fs.open(lock.lockPath, "wx", 0o600),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
await releaseLocks();
|
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
throw new Error("git worktree cleanup lock is already held");
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
// Branch deletion must acquire this native ref lock itself. Callers release
|
|
// only that lock after the guarded worktree removal, while retaining the
|
|
// index and HEAD locks until the whole cleanup transaction finishes.
|
|
releaseBranchRefLock: () => releaseLocks("branch"),
|
|
release: () => releaseLocks(),
|
|
};
|
|
}
|
|
|
|
async function deleteGitBranchAtVerifiedTip(input: {
|
|
repoRoot: string;
|
|
branchName: string;
|
|
expectedHeadSha: string;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
metadata: Record<string, unknown>;
|
|
}) {
|
|
const commonDirRaw = await runGit(["rev-parse", "--git-common-dir"], input.repoRoot);
|
|
const commonDir = path.isAbsolute(commonDirRaw)
|
|
? commonDirRaw
|
|
: path.resolve(input.repoRoot, commonDirRaw);
|
|
const detachedGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-branch-delete-"));
|
|
const detachedWorktree = `${detachedGitDir}-worktree`;
|
|
|
|
try {
|
|
// `git branch -d` refuses branches checked out by another worktree and its
|
|
// ref transaction fails if the tip changes concurrently. A detached HEAD
|
|
// at the delivered SHA additionally lets squash/cross-branch deliveries
|
|
// delete only the exact branch history that was verified before cleanup.
|
|
await Promise.all([
|
|
fs.writeFile(path.join(detachedGitDir, "HEAD"), `${input.expectedHeadSha}\n`, "utf8"),
|
|
fs.writeFile(path.join(detachedGitDir, "commondir"), `${commonDir}\n`, "utf8"),
|
|
]);
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_cleanup",
|
|
args: [
|
|
`--git-dir=${detachedGitDir}`,
|
|
`--work-tree=${detachedWorktree}`,
|
|
"branch",
|
|
"-d",
|
|
input.branchName,
|
|
],
|
|
cwd: input.repoRoot,
|
|
metadata: input.metadata,
|
|
successMessage: `Deleted branch ${input.branchName}\n`,
|
|
failureLabel: `git branch -d ${input.branchName}`,
|
|
});
|
|
} finally {
|
|
await fs.rm(detachedGitDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|
workspace: {
|
|
id: string;
|
|
cwd: string | null;
|
|
providerType: string;
|
|
providerRef: string | null;
|
|
branchName: string | null;
|
|
repoUrl: string | null;
|
|
baseRef: string | null;
|
|
projectId: string | null;
|
|
projectWorkspaceId: string | null;
|
|
sourceIssueId: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
};
|
|
projectWorkspace?: {
|
|
cwd: string | null;
|
|
cleanupCommand: string | null;
|
|
} | null;
|
|
cleanupCommand?: string | null;
|
|
teardownCommand?: string | null;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
assertSafeToCleanup?: (() => Promise<void>) | null;
|
|
beforeBranchDelete?: (() => Promise<void>) | null;
|
|
expectedBranchHeadSha?: string | null;
|
|
runCleanupCommands?: boolean;
|
|
forceWorktreeRemoval?: boolean;
|
|
}) {
|
|
const warnings: string[] = [];
|
|
const workspacePath = input.workspace.providerRef ?? input.workspace.cwd;
|
|
const repoRoot = input.workspace.providerType === "git_worktree" && workspacePath
|
|
? await resolveGitRepoRootForWorkspaceCleanup(
|
|
workspacePath,
|
|
input.projectWorkspace?.cwd ?? null,
|
|
)
|
|
: null;
|
|
const cleanupEnv = buildExecutionWorkspaceCleanupEnv({
|
|
workspace: input.workspace,
|
|
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
|
|
});
|
|
// Callers can require the workspace to match an assessed snapshot before
|
|
// cleanup begins. Destructive paths recheck immediately before removal.
|
|
await input.assertSafeToCleanup?.();
|
|
let worktreeInstancePointer: WorktreeInstancePointer | null = null;
|
|
let expectedWorktreeInstanceId: string | null = null;
|
|
if (input.workspace.providerType === "git_worktree" && workspacePath) {
|
|
expectedWorktreeInstanceId = deriveWorktreeInstanceId(workspacePath);
|
|
try {
|
|
// Capture the pointer before custom cleanup commands can remove the repo-local env file.
|
|
worktreeInstancePointer = await readWorktreeInstancePointer(workspacePath);
|
|
} catch (err) {
|
|
warnings.push(`Could not read worktree instance pointer: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
// Local-directory ownership keeps the historical createdByRuntime signal.
|
|
// Git branch deletion additionally requires the version marker introduced
|
|
// with branch-level ownership semantics. Unmarked legacy rows fail closed:
|
|
// their worktrees are removable, but their branch refs are operator-owned.
|
|
const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
|
|
const branchCreatedByRuntime = isRuntimeOwnedGitBranch(input.workspace.metadata);
|
|
const cleanupCommands = input.runCleanupCommands === false
|
|
? []
|
|
: [
|
|
input.cleanupCommand ?? null,
|
|
input.projectWorkspace?.cleanupCommand ?? null,
|
|
input.teardownCommand ?? null,
|
|
]
|
|
.map((value) => asString(value, "").trim())
|
|
.filter(Boolean);
|
|
|
|
for (const command of cleanupCommands) {
|
|
try {
|
|
const resolvedCommand = repoRoot
|
|
? resolveRepoManagedWorkspaceCommand(command, repoRoot)
|
|
: command;
|
|
await recordWorkspaceCommandOperation(input.recorder, {
|
|
phase: "workspace_teardown",
|
|
command,
|
|
resolvedCommand,
|
|
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
|
|
env: cleanupEnv,
|
|
label: `Execution workspace cleanup command "${command}"`,
|
|
metadata: {
|
|
workspaceId: input.workspace.id,
|
|
workspacePath,
|
|
branchName: input.workspace.branchName,
|
|
providerType: input.workspace.providerType,
|
|
resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
|
|
},
|
|
successMessage: `Completed cleanup command "${command}"\n`,
|
|
});
|
|
} catch (err) {
|
|
warnings.push(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
if (worktreeInstancePointer && workspacePath && expectedWorktreeInstanceId) {
|
|
try {
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: worktreeInstancePointer,
|
|
workspaceId: input.workspace.id,
|
|
workspacePath,
|
|
expectedInstanceId: expectedWorktreeInstanceId,
|
|
expectedInstanceRoot:
|
|
typeof input.workspace.metadata?.[WORKTREE_INSTANCE_ROOT_METADATA_KEY] === "string"
|
|
? input.workspace.metadata[WORKTREE_INSTANCE_ROOT_METADATA_KEY]
|
|
: null,
|
|
recorder: input.recorder,
|
|
});
|
|
if (result.status === "refused") warnings.push(result.warning);
|
|
} catch (err) {
|
|
warnings.push(`Failed to clean worktree instance: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
if (input.workspace.providerType === "git_worktree" && workspacePath) {
|
|
const worktreeExists = await directoryExists(workspacePath);
|
|
if (worktreeExists) {
|
|
if (!repoRoot) {
|
|
warnings.push(`Could not resolve git repo root for "${workspacePath}".`);
|
|
} else {
|
|
try {
|
|
await input.assertSafeToCleanup?.();
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_cleanup",
|
|
args: [
|
|
"worktree",
|
|
"remove",
|
|
...(input.forceWorktreeRemoval === false ? [] : ["--force"]),
|
|
workspacePath,
|
|
],
|
|
cwd: repoRoot,
|
|
metadata: {
|
|
workspaceId: input.workspace.id,
|
|
workspacePath,
|
|
branchName: input.workspace.branchName,
|
|
cleanupAction: "worktree_remove",
|
|
},
|
|
successMessage: `Removed git worktree ${workspacePath}\n`,
|
|
failureLabel: `git worktree remove ${workspacePath}`,
|
|
});
|
|
} catch (err) {
|
|
warnings.push(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
}
|
|
if (branchCreatedByRuntime && input.workspace.branchName) {
|
|
if (!repoRoot) {
|
|
warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`);
|
|
} else {
|
|
try {
|
|
await input.beforeBranchDelete?.();
|
|
const metadata = {
|
|
workspaceId: input.workspace.id,
|
|
workspacePath,
|
|
branchName: input.workspace.branchName,
|
|
cleanupAction: "branch_delete",
|
|
};
|
|
if (input.expectedBranchHeadSha) {
|
|
await deleteGitBranchAtVerifiedTip({
|
|
repoRoot,
|
|
branchName: input.workspace.branchName,
|
|
expectedHeadSha: input.expectedBranchHeadSha,
|
|
recorder: input.recorder,
|
|
metadata,
|
|
});
|
|
} else {
|
|
await recordGitOperation(input.recorder, {
|
|
phase: "worktree_cleanup",
|
|
args: ["branch", "-d", input.workspace.branchName],
|
|
cwd: repoRoot,
|
|
metadata,
|
|
successMessage: `Deleted branch ${input.workspace.branchName}\n`,
|
|
failureLabel: `git branch -d ${input.workspace.branchName}`,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
warnings.push(`Skipped deleting branch "${input.workspace.branchName}": ${message}`);
|
|
}
|
|
}
|
|
}
|
|
} else if (input.workspace.providerType === "local_fs" && createdByRuntime && workspacePath) {
|
|
const projectWorkspaceCwd = input.projectWorkspace?.cwd ? path.resolve(input.projectWorkspace.cwd) : null;
|
|
const resolvedWorkspacePath = path.resolve(workspacePath);
|
|
const containsProjectWorkspace = projectWorkspaceCwd
|
|
? (
|
|
resolvedWorkspacePath === projectWorkspaceCwd ||
|
|
projectWorkspaceCwd.startsWith(`${resolvedWorkspacePath}${path.sep}`)
|
|
)
|
|
: false;
|
|
if (containsProjectWorkspace) {
|
|
warnings.push(`Refusing to remove path "${workspacePath}" because it contains the project workspace.`);
|
|
} else {
|
|
await input.assertSafeToCleanup?.();
|
|
await fs.rm(resolvedWorkspacePath, { recursive: true, force: true });
|
|
if (input.recorder) {
|
|
await input.recorder.recordOperation({
|
|
phase: "workspace_teardown",
|
|
cwd: projectWorkspaceCwd ?? process.cwd(),
|
|
metadata: {
|
|
workspaceId: input.workspace.id,
|
|
workspacePath: resolvedWorkspacePath,
|
|
cleanupAction: "remove_local_fs",
|
|
},
|
|
run: async () => ({
|
|
status: "succeeded",
|
|
exitCode: 0,
|
|
system: `Removed local workspace directory ${resolvedWorkspacePath}\n`,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const cleaned =
|
|
!workspacePath ||
|
|
!(await directoryExists(workspacePath));
|
|
|
|
return {
|
|
cleanedPath: workspacePath,
|
|
cleaned,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ports this process has handed to a starting runtime service but that no listener owns yet.
|
|
* The kernel will happily hand the same ephemeral port to two concurrent `listen(0)` probes
|
|
* once each probe socket closes, which is how two isolated workspaces starting at the same
|
|
* moment ended up fighting over one port pair. Reserving the port for the duration of the
|
|
* start makes concurrent allocations distinct.
|
|
*/
|
|
const inFlightAllocatedPorts = new Map<number, number>();
|
|
const PORT_RESERVATION_TTL_MS = 120_000;
|
|
const PORT_ALLOCATION_ATTEMPTS = 12;
|
|
|
|
export function resetRuntimeServicePortReservationsForTests() {
|
|
inFlightAllocatedPorts.clear();
|
|
}
|
|
|
|
function reservePortIfFree(port: number, now = Date.now()): boolean {
|
|
const heldUntil = inFlightAllocatedPorts.get(port);
|
|
if (heldUntil !== undefined && heldUntil > now) return false;
|
|
inFlightAllocatedPorts.set(port, now + PORT_RESERVATION_TTL_MS);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Claim the loopback port a start is about to bind. A configured port that reads free right now
|
|
* can still be taken by a sibling workspace that is mid-start — neither has bound yet and
|
|
* neither has a persisted row — so the claim is what makes the loser fail terminally here
|
|
* instead of racing to bind and then hanging on a readiness probe it can never satisfy.
|
|
* A start already holding the port from its own allocation must not be refused by itself.
|
|
*/
|
|
export function claimRuntimeServiceBindPort(bindPort: number, alreadyReservedPort: number | null) {
|
|
if (bindPort === alreadyReservedPort) return true;
|
|
return reservePortIfFree(bindPort);
|
|
}
|
|
|
|
function releasePortReservation(port: number | null | undefined) {
|
|
if (typeof port !== "number") return;
|
|
inFlightAllocatedPorts.delete(port);
|
|
}
|
|
|
|
async function probeEphemeralPort(): Promise<number> {
|
|
return await new Promise<number>((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
server.close((err) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
if (!address || typeof address === "string") {
|
|
reject(new Error("Failed to allocate port"));
|
|
return;
|
|
}
|
|
resolve(address.port);
|
|
});
|
|
});
|
|
server.on("error", reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Execution workspaces whose exclusive lease is still open.
|
|
*
|
|
* "Open" is deliberately generous — every non-archived, non-closed status
|
|
* counts — because the reservation must outlive the *process*, not track it.
|
|
* A lane that is stopped, torn down, and reported `removed` still owns its
|
|
* pair until the workspace itself is released (PAP-17419).
|
|
*/
|
|
async function readActiveExecutionWorkspaceLeases(db: Db | undefined, companyId: string): Promise<Set<string>> {
|
|
if (!db) return new Set<string>();
|
|
const rows = await db
|
|
.select({ id: executionWorkspaces.id })
|
|
.from(executionWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(executionWorkspaces.companyId, companyId),
|
|
inArray(executionWorkspaces.status, [...OPEN_EXECUTION_WORKSPACE_LEASE_STATUSES]),
|
|
isNull(executionWorkspaces.closedAt),
|
|
),
|
|
);
|
|
return new Set(rows.map((row) => row.id));
|
|
}
|
|
|
|
/**
|
|
* Every reservation view the allocator must respect, merged into one ledger.
|
|
*
|
|
* This replaced a set-of-ports that only ever saw rows whose `exposure.state`
|
|
* was not `removed`. That view could not represent the case that actually
|
|
* broke: a leased workspace whose exposure had been torn down. See
|
|
* `port-reservation.ts` for why the pair is re-derived from the `port` column.
|
|
*/
|
|
async function buildCompanyExposureReservationLedger(input: {
|
|
db?: Db;
|
|
companyId: string;
|
|
brokerMappings?: BrokerMappingSnapshot[];
|
|
}): Promise<ExposureReservationLedger> {
|
|
const inMemoryRuntimes: InMemoryExposureSnapshot[] = [];
|
|
for (const record of runtimeServicesById.values()) {
|
|
if (record.companyId !== input.companyId || !record.exposure) continue;
|
|
inMemoryRuntimes.push({
|
|
runtimeServiceId: record.id,
|
|
executionWorkspaceId: record.executionWorkspaceId,
|
|
projectWorkspaceId: record.projectWorkspaceId,
|
|
issueId: record.issueId,
|
|
ports: record.exposure.listeners.map((listener) => listener.targetPort),
|
|
});
|
|
}
|
|
|
|
const persistedRows: PersistedExposureRowSnapshot[] = input.db
|
|
? (
|
|
await input.db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
status: workspaceRuntimeServices.status,
|
|
port: workspaceRuntimeServices.port,
|
|
exposure: workspaceRuntimeServices.exposure,
|
|
executionWorkspaceId: workspaceRuntimeServices.executionWorkspaceId,
|
|
projectWorkspaceId: workspaceRuntimeServices.projectWorkspaceId,
|
|
issueId: workspaceRuntimeServices.issueId,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(eq(workspaceRuntimeServices.companyId, input.companyId))
|
|
).map((row) => ({
|
|
id: row.id,
|
|
status: row.status,
|
|
port: row.port,
|
|
exposure: row.exposure,
|
|
executionWorkspaceId: row.executionWorkspaceId,
|
|
projectWorkspaceId: row.projectWorkspaceId,
|
|
issueId: row.issueId,
|
|
}))
|
|
: [];
|
|
|
|
return buildExposureReservationLedger({
|
|
persistedRows,
|
|
inMemoryRuntimes,
|
|
brokerMappings: input.brokerMappings ?? [],
|
|
quarantinedPorts: quarantinedRuntimeExposurePorts,
|
|
activeExecutionWorkspaceIds: await readActiveExecutionWorkspaceLeases(input.db, input.companyId),
|
|
inFlightClaimedPorts: exposurePortPairClaims.activePorts(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rows Paperclip reports stopped/removed whose reserved pair is still live on
|
|
* the host or still mapped to someone else (PAP-17419 regression #3).
|
|
*
|
|
* The point is visibility. A false `stopped`/`removed` row used to be
|
|
* indistinguishable from a genuinely released one, so the pair silently
|
|
* returned to the free list and the next managed start collided with — or
|
|
* adopted — an unrelated workspace's service. Surfacing it does not stop or
|
|
* mutate the occupying service; that stays the owning issue's call.
|
|
*/
|
|
async function detectPersistedExposureReservationDrift(input: {
|
|
rows: ReadonlyArray<{
|
|
id: string;
|
|
status: string;
|
|
port: number | null;
|
|
exposure: RuntimeExposureStatus | null;
|
|
executionWorkspaceId: string | null;
|
|
projectWorkspaceId: string | null;
|
|
issueId: string | null;
|
|
}>;
|
|
ownedListeners: Awaited<ReturnType<BrokerClient["list"]>> | null;
|
|
}) {
|
|
const snapshots: PersistedExposureRowSnapshot[] = input.rows.map((row) => ({
|
|
id: row.id,
|
|
status: row.status,
|
|
port: row.port,
|
|
exposure: row.exposure,
|
|
executionWorkspaceId: row.executionWorkspaceId,
|
|
projectWorkspaceId: row.projectWorkspaceId,
|
|
issueId: row.issueId,
|
|
}));
|
|
|
|
// Probe only the ports dormant rows actually reserve; a startup sweep must not
|
|
// walk the whole dedicated range.
|
|
const candidatePorts = new Set<number>();
|
|
for (const row of snapshots) {
|
|
if (row.status !== "stopped" && row.status !== "failed" && row.exposure && row.exposure.state !== "removed") {
|
|
continue;
|
|
}
|
|
for (const port of collectRowExposurePorts(row)) candidatePorts.add(port);
|
|
}
|
|
|
|
const livePorts = new Set<number>();
|
|
for (const port of candidatePorts) {
|
|
// "Not bindable" is the liveness signal the rest of this module already uses.
|
|
const available = await workspaceRuntimeExposureDeps.isPortAvailable(port).catch(() => true);
|
|
if (!available) livePorts.add(port);
|
|
}
|
|
|
|
return findExposureReservationDrift({
|
|
persistedRows: snapshots,
|
|
livePorts,
|
|
listenerOwners: await readExposureListenerOwners([...livePorts]),
|
|
brokerMappings: (input.ownedListeners ?? []).map((listener) => ({
|
|
runtimeId: listener.runtimeId,
|
|
port: listener.port,
|
|
})),
|
|
});
|
|
}
|
|
|
|
/** Paperclip-owned Serve mappings, or null when the broker cannot be read. */
|
|
async function readBrokerExposureMappings(): Promise<BrokerMappingSnapshot[] | null> {
|
|
try {
|
|
const owned = await workspaceRuntimeExposureDeps.broker.list();
|
|
return owned.map((listener) => ({ runtimeId: listener.runtimeId, port: listener.port }));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve who owns the process listening on each of a pair's ports.
|
|
*
|
|
* A port with no listener is absent from the map; a port with a listener we
|
|
* cannot attribute maps to `null`, which the mediator treats as a conflict.
|
|
* Attribution goes through this process's own runtime records: a pid we did not
|
|
* start is by definition not ours to adopt.
|
|
*/
|
|
async function readExposureListenerOwners(ports: number[]): Promise<Map<number, ExposureOwnerIdentity | null>> {
|
|
const owners = new Map<number, ExposureOwnerIdentity | null>();
|
|
for (const port of ports) {
|
|
const ownerPid = await readLocalServicePortOwner(port).catch(() => null);
|
|
if (!ownerPid) continue;
|
|
let identity: ExposureOwnerIdentity | null = null;
|
|
for (const record of runtimeServicesById.values()) {
|
|
const recordPid = record.child?.pid ?? null;
|
|
if (recordPid === null) continue;
|
|
if (recordPid !== ownerPid && record.processGroupId !== ownerPid) continue;
|
|
identity = {
|
|
runtimeServiceId: record.id,
|
|
executionWorkspaceId: record.executionWorkspaceId,
|
|
projectWorkspaceId: record.projectWorkspaceId,
|
|
issueId: record.issueId,
|
|
};
|
|
break;
|
|
}
|
|
owners.set(port, identity);
|
|
}
|
|
return owners;
|
|
}
|
|
|
|
async function allocateAndReserveExposure(input: {
|
|
db?: Db;
|
|
companyId: string;
|
|
runtimeId: string;
|
|
config: RuntimeExposureConfigInput;
|
|
/** Identity claiming the pair; governs every ownership decision below. */
|
|
claimant: ExposureOwnerIdentity;
|
|
/** Port this runtime already used, preserved when it is still safe to use. */
|
|
preferredAppPort?: number | null;
|
|
}): Promise<{ appPort: number; hmrPort: number; status: RuntimeExposureStatus; handle: string }> {
|
|
const brokerMappings = await readBrokerExposureMappings();
|
|
const ledger = await buildCompanyExposureReservationLedger({
|
|
db: input.db,
|
|
companyId: input.companyId,
|
|
brokerMappings: brokerMappings ?? [],
|
|
});
|
|
// Serve mappings are checked as their own view, not folded into the ledger:
|
|
// an unreadable broker must not silently downgrade to "no mapping exists".
|
|
const serveMappingOwners = new Map<number, ExposureOwnerIdentity | null>();
|
|
for (const mapping of brokerMappings ?? []) {
|
|
serveMappingOwners.set(mapping.port, ledger.reservationByPort.get(mapping.port)?.owner ?? null);
|
|
}
|
|
|
|
// Reserve only what this claimant may NOT have. A leaseholder restarting its
|
|
// own lane has to be offered its own pair back, or every restart would walk
|
|
// the range and undo "keep existing runtime ports when safe" (PAP-17158).
|
|
// Quarantined ports are withheld from everyone, including the owner.
|
|
const reserved = new Set<number>();
|
|
for (const [port, reservation] of ledger.reservationByPort) {
|
|
if (reservation.source === "quarantine" || !isExposureAdoptionPermitted(reservation.owner, input.claimant)) {
|
|
reserved.add(port);
|
|
}
|
|
}
|
|
const retryable = new Set(["reservation_conflict", "manual_mapping_present", "quarantined"]);
|
|
const claimed: Array<{ appPort: number; hmrPort: number }> = [];
|
|
let preferredAppPort = input.preferredAppPort ?? null;
|
|
try {
|
|
while (true) {
|
|
const pair = await allocateExposurePortPair({
|
|
isPortAvailable: workspaceRuntimeExposureDeps.isPortAvailable,
|
|
reserved,
|
|
preferredAppPort,
|
|
claimPair: (candidate) => exposurePortPairClaims.claim(candidate),
|
|
});
|
|
claimed.push(pair);
|
|
|
|
// Complete mediation before the broker is asked for anything: persisted
|
|
// reservations were already folded into `reserved`, so what remains is the
|
|
// live host — the listener actually bound, and the Serve mapping actually
|
|
// published. Either one belonging to a different execution workspace is
|
|
// terminal, never an adoption.
|
|
const conflict = findExposurePairConflict({
|
|
pair,
|
|
claimant: input.claimant,
|
|
ledger,
|
|
listenerOwners: await readExposureListenerOwners([pair.appPort, pair.hmrPort]),
|
|
serveMappingOwners,
|
|
});
|
|
if (conflict) throw new ExposurePortOwnershipConflictError(conflict);
|
|
|
|
const result = await reserveExposure(workspaceRuntimeExposureDeps, {
|
|
runtimeId: input.runtimeId,
|
|
config: input.config,
|
|
appPort: pair.appPort,
|
|
});
|
|
if (result.handle) {
|
|
// Keep this pair's claim; the caller releases it on stop/teardown.
|
|
claimed.pop();
|
|
return { appPort: pair.appPort, hmrPort: pair.hmrPort, status: result.status, handle: result.handle };
|
|
}
|
|
if (!result.status.lastError || !retryable.has(result.status.lastError)) {
|
|
throw new Error(`HTTPS exposure reservation failed: ${result.status.lastError ?? "unknown broker error"}`);
|
|
}
|
|
reserved.add(pair.appPort);
|
|
reserved.add(pair.hmrPort);
|
|
// The preference lost its race with a conflicting/manual/quarantined
|
|
// mapping; drop it so the retry scans instead of re-offering the same port.
|
|
preferredAppPort = null;
|
|
}
|
|
} finally {
|
|
// Every pair this call took but did not hand back — rejected candidates and
|
|
// the in-flight pair on a thrown failure — goes back immediately. Leaving
|
|
// them held would burn the range down over a retry storm.
|
|
for (const pair of claimed) exposurePortPairClaims.release(pair);
|
|
}
|
|
}
|
|
|
|
async function canBindRuntimePort(port: number): Promise<boolean> {
|
|
return await new Promise<boolean>((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once("error", (error: NodeJS.ErrnoException) => {
|
|
if (error.code === "EADDRINUSE" || error.code === "EACCES") {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
reject(error);
|
|
});
|
|
server.listen(port, "127.0.0.1", () => {
|
|
server.close((error) => {
|
|
if (error) reject(error);
|
|
else resolve(true);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* True when a loopback listener is present on the port, read WITHOUT binding it.
|
|
*
|
|
* The readiness wait must never bind the exact port the guest is about to bind.
|
|
* A probe `listen()` holds the port for the length of one bind/close, and if the
|
|
* guest's own `listen()` lands in that window the guest fails with EADDRINUSE on
|
|
* its assigned port. That self-inflicted race is the runtime exposure port flake
|
|
* (a slow guest under load loses the race to the parent probe). A `/proc` read
|
|
* carries the same "a listener appeared" signal with no bind. Where `/proc` is
|
|
* absent (non-Linux dev hosts), fall back to the bind probe; those hosts do not
|
|
* run the concurrent managed lanes that expose the race.
|
|
*/
|
|
async function hasLoopbackPortListener(port: number): Promise<boolean> {
|
|
const facts = await readListenerBindFacts(port).catch(() => null);
|
|
if (facts) return facts.present;
|
|
return !(await canBindRuntimePort(port));
|
|
}
|
|
|
|
/**
|
|
* True when one line of the failure text reports an EADDRINUSE bind conflict on
|
|
* the given port.
|
|
*
|
|
* Node prints the failing bind on a single line, for example
|
|
* `Error: listen EADDRINUSE: address already in use 127.0.0.1:42000`. The match
|
|
* requires the EADDRINUSE marker and the port on the SAME line. So an
|
|
* auxiliary-port conflict on one line cannot combine with an unrelated
|
|
* assigned-port mention on a different, benign line and trigger a wrong
|
|
* quarantine.
|
|
*
|
|
* Node formats a bind address as `host:port`, so the failing port always
|
|
* follows a colon (for example `127.0.0.1:42000` or `:::42000`). The match
|
|
* requires that colon and a full-number boundary. So a different port in the
|
|
* same line cannot look like the assigned app or HMR port, and port 4200 never
|
|
* matches `:42000`.
|
|
*/
|
|
function eaddrinuseTextNamesPort(text: string, port: number): boolean {
|
|
const eaddrinusePattern = /EADDRINUSE|address already in use/i;
|
|
const portPattern = new RegExp(`:${port}(?![0-9])`);
|
|
return text
|
|
.split(/\r?\n/)
|
|
.some((line) => eaddrinusePattern.test(line) && portPattern.test(line));
|
|
}
|
|
|
|
/** Live host listener state for one assigned exposure port, read after a failure. */
|
|
export interface ExposurePortHostState {
|
|
port: number;
|
|
/** True when the failure text reports EADDRINUSE for this port on one line. */
|
|
named: boolean;
|
|
/** True when a real listener holds the port now (from /proc or lsof). */
|
|
listenerPresent: boolean;
|
|
/** The listener pid, or null when unknown. */
|
|
ownerPid: number | null;
|
|
/** The process group id of the listener owner, or null when unknown. */
|
|
ownerProcessGroupId: number | null;
|
|
}
|
|
|
|
/**
|
|
* Decide which assigned exposure ports a real host listener owns after a guest
|
|
* start failure.
|
|
*
|
|
* The guest owns its own output, so an assigned-port EADDRINUSE line is a claim,
|
|
* not proof. A managed guest can print a synthetic EADDRINUSE line for its
|
|
* assigned port with no host listener behind it. A quarantine on that text alone
|
|
* would drain the shared exposure-port pool across repeated starts. So a port
|
|
* counts as a host collision only when all of the following hold:
|
|
* - the failure text names the port with EADDRINUSE, and
|
|
* - a real listener is present on the port now, and
|
|
* - the listener owner is not the guest process the runtime just launched, nor a
|
|
* descendant of it.
|
|
*
|
|
* The runtime launches the guest as a shell process group leader, so the real dev
|
|
* server usually binds the port from a descendant, not the shell pid itself. The
|
|
* ownership test therefore matches the shell pid against both the owner pid and
|
|
* the owner process group id. This is the same process-group attribution that
|
|
* `isLocalServiceProcessOwnedBy` applies on the host platform. A raw pid equality
|
|
* would treat a guest descendant as an external owner and quarantine a valid pair.
|
|
*
|
|
* An unknown owner with a present listener counts as a host owner: the /proc read
|
|
* proves a listener, and a guest that lost its assigned port does not hold it.
|
|
*/
|
|
export function classifyExposureHostCollisions(input: {
|
|
childPid: number | null;
|
|
ports: ExposurePortHostState[];
|
|
}): { hostCollisionPorts: number[]; hostCollision: boolean } {
|
|
const hostCollisionPorts: number[] = [];
|
|
for (const state of input.ports) {
|
|
const guestOwnsPort =
|
|
input.childPid != null &&
|
|
(state.ownerPid === input.childPid || state.ownerProcessGroupId === input.childPid);
|
|
if (state.named && state.listenerPresent && !guestOwnsPort) {
|
|
hostCollisionPorts.push(state.port);
|
|
}
|
|
}
|
|
return { hostCollisionPorts, hostCollision: hostCollisionPorts.length > 0 };
|
|
}
|
|
|
|
async function readReservedRuntimePorts(input: {
|
|
db?: Db;
|
|
ports: number[];
|
|
executionWorkspaceId: string | null;
|
|
}) {
|
|
if (!input.db || input.ports.length === 0) return new Set<number>();
|
|
const lowerBound = Math.min(...input.ports);
|
|
const upperBound = Math.max(...input.ports);
|
|
const otherWorkspaceCondition = input.executionWorkspaceId
|
|
? or(
|
|
isNull(workspaceRuntimeServices.executionWorkspaceId),
|
|
ne(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId),
|
|
)
|
|
: undefined;
|
|
const rows = await input.db
|
|
.select({ port: workspaceRuntimeServices.port })
|
|
.from(workspaceRuntimeServices)
|
|
.where(
|
|
and(
|
|
inArray(workspaceRuntimeServices.status, [...ACTIVE_RUNTIME_PORT_RESERVATION_STATUSES]),
|
|
gte(workspaceRuntimeServices.port, lowerBound),
|
|
lte(workspaceRuntimeServices.port, upperBound),
|
|
otherWorkspaceCondition,
|
|
),
|
|
);
|
|
return new Set(rows.flatMap((row) => row.port === null ? [] : [row.port]));
|
|
}
|
|
|
|
async function buildRuntimePortAllocationConflict(input: {
|
|
db?: Db;
|
|
companyId: string;
|
|
executionWorkspaceId: string | null;
|
|
preferredPort: number;
|
|
attemptedPorts: number[];
|
|
}) {
|
|
const conflictRows = input.db && input.attemptedPorts.length > 0
|
|
? await input.db
|
|
.select({
|
|
port: workspaceRuntimeServices.port,
|
|
executionWorkspaceId: workspaceRuntimeServices.executionWorkspaceId,
|
|
projectWorkspaceId: workspaceRuntimeServices.projectWorkspaceId,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(
|
|
and(
|
|
eq(workspaceRuntimeServices.companyId, input.companyId),
|
|
inArray(workspaceRuntimeServices.status, [...ACTIVE_RUNTIME_PORT_RESERVATION_STATUSES]),
|
|
inArray(workspaceRuntimeServices.port, input.attemptedPorts),
|
|
input.executionWorkspaceId
|
|
? or(
|
|
isNull(workspaceRuntimeServices.executionWorkspaceId),
|
|
ne(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId),
|
|
)
|
|
: undefined,
|
|
),
|
|
)
|
|
: [];
|
|
const attemptedRank = new Map(input.attemptedPorts.map((port, index) => [port, index]));
|
|
const authorizedConflict = conflictRows
|
|
.sort((left, right) =>
|
|
(attemptedRank.get(left.port ?? -1) ?? Number.MAX_SAFE_INTEGER)
|
|
- (attemptedRank.get(right.port ?? -1) ?? Number.MAX_SAFE_INTEGER))
|
|
.find((row) => row.executionWorkspaceId || row.projectWorkspaceId) ?? null;
|
|
const remediation =
|
|
"Stop the conflicting managed service or configure a different preferred port, then retry the start.";
|
|
|
|
return conflict(
|
|
`No safe runtime service port is available in the bounded allocation range starting at ${input.preferredPort}.`,
|
|
{
|
|
code: "workspace_runtime_port_allocation_exhausted",
|
|
port: input.preferredPort,
|
|
attemptedPortCount: input.attemptedPorts.length,
|
|
...(authorizedConflict?.executionWorkspaceId
|
|
? { conflictingExecutionWorkspaceId: authorizedConflict.executionWorkspaceId }
|
|
: {}),
|
|
...(authorizedConflict?.projectWorkspaceId
|
|
? { conflictingProjectWorkspaceId: authorizedConflict.projectWorkspaceId }
|
|
: {}),
|
|
remediation,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function allocateIsolatedWorkspacePort(input: {
|
|
db?: Db;
|
|
companyId: string;
|
|
executionWorkspaceId: string;
|
|
preferredPort: number;
|
|
stoppedPort: number | null;
|
|
excludedPorts: ReadonlySet<number>;
|
|
}) {
|
|
const lastPort = Math.min(
|
|
65_535,
|
|
input.preferredPort + WORKSPACE_RUNTIME_PORT_ALLOCATION_ATTEMPTS - 1,
|
|
);
|
|
const candidates: number[] = [];
|
|
const addCandidate = (port: number | null) => {
|
|
if (
|
|
port === null
|
|
|| port < input.preferredPort
|
|
|| port > lastPort
|
|
|| input.excludedPorts.has(port)
|
|
|| candidates.includes(port)
|
|
) return;
|
|
candidates.push(port);
|
|
};
|
|
addCandidate(input.stoppedPort);
|
|
for (let port = input.preferredPort; port <= lastPort; port += 1) addCandidate(port);
|
|
|
|
const reservedPorts = await readReservedRuntimePorts({
|
|
db: input.db,
|
|
ports: candidates,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
});
|
|
for (const port of candidates) {
|
|
if (reservedPorts.has(port)) continue;
|
|
// Claim the candidate in-process before probing it: a sibling start that already holds it
|
|
// has not persisted a row yet, so `reservedPorts` cannot see it and both lanes would
|
|
// otherwise pick the same port (PAP-17249).
|
|
if (!reservePortIfFree(port)) continue;
|
|
if (await canBindRuntimePort(port)) return port;
|
|
releasePortReservation(port);
|
|
}
|
|
const attemptedPorts = [
|
|
...input.excludedPorts,
|
|
...candidates,
|
|
].filter((port, index, ports) =>
|
|
port >= input.preferredPort
|
|
&& port <= lastPort
|
|
&& ports.indexOf(port) === index);
|
|
|
|
throw await buildRuntimePortAllocationConflict({
|
|
db: input.db,
|
|
companyId: input.companyId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
preferredPort: input.preferredPort,
|
|
attemptedPorts,
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Allocate a loopback port that no other in-flight managed start already holds and that no
|
|
* live process owns. Callers must {@link releasePortReservation} once the service either
|
|
* reached a terminal state or bound the port itself.
|
|
*/
|
|
export async function allocateRuntimeServicePort(overrides?: {
|
|
probe?: () => Promise<number>;
|
|
portOwnerLookup?: (port: number) => Promise<number | null>;
|
|
}): Promise<number> {
|
|
const probe = overrides?.probe ?? probeEphemeralPort;
|
|
const portOwnerLookup = overrides?.portOwnerLookup ?? readLocalServicePortOwner;
|
|
let lastCandidate: number | null = null;
|
|
for (let attempt = 0; attempt < PORT_ALLOCATION_ATTEMPTS; attempt += 1) {
|
|
const candidate = await probe();
|
|
lastCandidate = candidate;
|
|
// Never hand back a port inside the runtime exposure app-port range. The
|
|
// reconciler classifies a persisted row by its port. A port in that range
|
|
// marks the row as an exposure reservation, not a managed auto port. An auto
|
|
// port from that range makes the reconciler read a stopped managed row as an
|
|
// exposure reservation and report drift instead of the adoption. The kernel
|
|
// can hand out an ephemeral port in that band, so skip the candidate here.
|
|
if (isRuntimeExposureAppPort(candidate)) continue;
|
|
if (!reservePortIfFree(candidate)) continue;
|
|
const ownerPid = await portOwnerLookup(candidate);
|
|
if (!ownerPid) return candidate;
|
|
releasePortReservation(candidate);
|
|
}
|
|
throw new Error(
|
|
`Could not allocate a free loopback port for a managed runtime service after ${PORT_ALLOCATION_ATTEMPTS} attempts`
|
|
+ `${lastCandidate ? ` (last candidate ${lastCandidate})` : ""}.`,
|
|
);
|
|
}
|
|
|
|
function buildTemplateData(input: {
|
|
workspace: RealizedExecutionWorkspace;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
adapterEnv: Record<string, string>;
|
|
port: number | null;
|
|
}) {
|
|
return {
|
|
workspace: {
|
|
cwd: input.workspace.cwd,
|
|
branchName: input.workspace.branchName ?? "",
|
|
worktreePath: input.workspace.worktreePath ?? "",
|
|
repoUrl: input.workspace.repoUrl ?? "",
|
|
repoRef: input.workspace.repoRef ?? "",
|
|
env: input.adapterEnv,
|
|
},
|
|
issue: {
|
|
id: input.issue?.id ?? "",
|
|
identifier: input.issue?.identifier ?? "",
|
|
title: input.issue?.title ?? "",
|
|
},
|
|
agent: {
|
|
id: input.agent.id ?? "",
|
|
name: input.agent.name,
|
|
},
|
|
port: input.port ?? "",
|
|
};
|
|
}
|
|
|
|
function renderRuntimeServiceEnv(input: {
|
|
envConfig: Record<string, unknown>;
|
|
templateData: ReturnType<typeof buildTemplateData>;
|
|
}) {
|
|
const rendered: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(input.envConfig)) {
|
|
if (typeof value !== "string") continue;
|
|
rendered[key] = renderTemplate(value, input.templateData);
|
|
}
|
|
return rendered;
|
|
}
|
|
|
|
function resolveRuntimeServiceReuseIdentity(input: {
|
|
service: Record<string, unknown>;
|
|
workspace: RealizedExecutionWorkspace;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
adapterEnv: Record<string, string>;
|
|
scopeType: RuntimeServiceRef["scopeType"];
|
|
scopeId: string | null;
|
|
}): {
|
|
serviceName: string;
|
|
lifecycle: RuntimeServiceRef["lifecycle"];
|
|
command: string;
|
|
serviceCwd: string;
|
|
envConfig: Record<string, unknown>;
|
|
envFingerprint: string;
|
|
explicitPort: number;
|
|
identityPort: number | null;
|
|
reuseKey: string | null;
|
|
} {
|
|
const serviceName = asString(input.service.name, "service");
|
|
const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
|
|
const command = asString(input.service.command, "");
|
|
const serviceCwdTemplate = asString(input.service.cwd, ".");
|
|
const portConfig = parseObject(input.service.port);
|
|
const envConfig = parseObject(input.service.env);
|
|
const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0));
|
|
const identityPort = explicitPort > 0 ? explicitPort : null;
|
|
const templateData = buildTemplateData({
|
|
workspace: input.workspace,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
port: identityPort,
|
|
});
|
|
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
|
|
const renderedEnv = renderRuntimeServiceEnv({
|
|
envConfig,
|
|
templateData,
|
|
});
|
|
const envFingerprint = createHash("sha256").update(stableStringify(renderedEnv)).digest("hex");
|
|
const reuseKey =
|
|
lifecycle === "shared"
|
|
? createHash("sha256")
|
|
.update(
|
|
stableStringify({
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
serviceName,
|
|
command,
|
|
cwd: serviceCwd,
|
|
port: identityPort,
|
|
env: renderedEnv,
|
|
expose: input.service.expose ?? null,
|
|
}),
|
|
)
|
|
.digest("hex")
|
|
: null;
|
|
|
|
return {
|
|
serviceName,
|
|
lifecycle,
|
|
command,
|
|
serviceCwd,
|
|
envConfig,
|
|
envFingerprint,
|
|
explicitPort,
|
|
identityPort,
|
|
reuseKey,
|
|
};
|
|
}
|
|
|
|
function resolveWorkspaceCommandExecution(input: {
|
|
command: Record<string, unknown>;
|
|
workspace: RealizedExecutionWorkspace;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
adapterEnv: Record<string, string>;
|
|
}) {
|
|
const name =
|
|
asString(input.command.name, "")
|
|
|| asString(input.command.label, "")
|
|
|| asString(input.command.title, "")
|
|
|| "workspace command";
|
|
const command = asString(input.command.command, "");
|
|
const templateData = buildTemplateData({
|
|
workspace: input.workspace,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
port: null,
|
|
});
|
|
const cwd = resolveConfiguredPath(
|
|
renderTemplate(asString(input.command.cwd, "."), templateData),
|
|
input.workspace.cwd,
|
|
);
|
|
const env = {
|
|
...sanitizeRuntimeServiceBaseEnv(process.env),
|
|
...input.adapterEnv,
|
|
...renderRuntimeServiceEnv({
|
|
envConfig: parseObject(input.command.env),
|
|
templateData,
|
|
}),
|
|
} as Record<string, string>;
|
|
|
|
return {
|
|
name,
|
|
command,
|
|
cwd,
|
|
env,
|
|
};
|
|
}
|
|
|
|
export async function runWorkspaceJobForControl(input: {
|
|
actor: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
workspace: RealizedExecutionWorkspace;
|
|
command: Record<string, unknown>;
|
|
adapterEnv?: Record<string, string>;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
}) {
|
|
const resolved = resolveWorkspaceCommandExecution({
|
|
command: input.command,
|
|
workspace: input.workspace,
|
|
agent: input.actor,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv ?? {},
|
|
});
|
|
if (!resolved.command) {
|
|
throw new Error(`Workspace job "${resolved.name}" is missing command`);
|
|
}
|
|
|
|
await ensureServerWorkspaceLinksCurrent(resolved.cwd);
|
|
return await recordWorkspaceCommandOperation(input.recorder, {
|
|
phase: "workspace_provision",
|
|
command: resolved.command,
|
|
cwd: resolved.cwd,
|
|
env: resolved.env,
|
|
label: `Workspace job "${resolved.name}"`,
|
|
metadata: {
|
|
workspaceCommandKind: "job",
|
|
workspaceCommandName: resolved.name,
|
|
...(input.metadata ?? {}),
|
|
},
|
|
successMessage: `Completed workspace job "${resolved.name}"\n`,
|
|
});
|
|
}
|
|
|
|
function resolveServiceScopeId(input: {
|
|
service: Record<string, unknown>;
|
|
workspace: RealizedExecutionWorkspace;
|
|
executionWorkspaceId?: string | null;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
runId: string;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
}): {
|
|
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
|
scopeId: string | null;
|
|
} {
|
|
const scopeTypeRaw = asString(input.service.reuseScope, input.service.lifecycle === "shared" ? "project_workspace" : "run");
|
|
const scopeType =
|
|
scopeTypeRaw === "project_workspace" ||
|
|
scopeTypeRaw === "execution_workspace" ||
|
|
scopeTypeRaw === "agent"
|
|
? scopeTypeRaw
|
|
: "run";
|
|
if (scopeType === "project_workspace") return { scopeType, scopeId: input.workspace.workspaceId ?? input.workspace.projectId };
|
|
if (scopeType === "execution_workspace") {
|
|
return { scopeType, scopeId: input.executionWorkspaceId ?? input.workspace.cwd };
|
|
}
|
|
if (scopeType === "agent") return { scopeType, scopeId: input.agent.id };
|
|
return { scopeType: "run" as const, scopeId: input.runId };
|
|
}
|
|
|
|
function looksLikeWorkspaceDevServerCommand(command: string) {
|
|
const normalized = command.trim().toLowerCase();
|
|
if (!normalized) return false;
|
|
return /(?:^|\s)(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?dev(?:\s|$)/.test(normalized);
|
|
}
|
|
|
|
export function resolveWorkspaceRuntimeReadinessTimeoutSec(service: Record<string, unknown>) {
|
|
const readiness = parseObject(service.readiness);
|
|
const explicitTimeoutSec = asNumber(readiness.timeoutSec, 0);
|
|
if (explicitTimeoutSec > 0) {
|
|
return Math.max(1, explicitTimeoutSec);
|
|
}
|
|
return looksLikeWorkspaceDevServerCommand(asString(service.command, "")) ? 90 : 30;
|
|
}
|
|
|
|
/**
|
|
* Longest a single readiness probe may stay outstanding. Without this an unrelated process
|
|
* that accepts the connection but never answers (exactly what a reallocated port produces)
|
|
* parks `fetch` forever, the readiness deadline is never re-checked, and the managed start
|
|
* never reaches a terminal state.
|
|
*/
|
|
export const RUNTIME_SERVICE_READINESS_PROBE_TIMEOUT_MS = 5_000;
|
|
|
|
export async function waitForRuntimeServiceReadiness(input: {
|
|
service: Record<string, unknown>;
|
|
serviceName?: string | null;
|
|
command?: string | null;
|
|
url: string | null;
|
|
readinessUrl: string | null;
|
|
fetchImpl?: typeof fetch;
|
|
now?: () => number;
|
|
}) {
|
|
const readiness = parseObject(input.service.readiness);
|
|
const readinessType = asString(readiness.type, "");
|
|
const readinessTargetUrl = input.readinessUrl ?? input.url;
|
|
if (readinessType !== "http" || !readinessTargetUrl) return;
|
|
const readinessUrl = resolveRuntimeServiceHealthUrl(readinessTargetUrl, {
|
|
serviceName: input.serviceName,
|
|
command: input.command,
|
|
});
|
|
if (!readinessUrl) {
|
|
throw new Error(`Readiness check failed: could not resolve health URL for ${input.url}`);
|
|
}
|
|
const fetchImpl = input.fetchImpl ?? fetch;
|
|
const now = input.now ?? Date.now;
|
|
const timeoutSec = resolveWorkspaceRuntimeReadinessTimeoutSec(input.service);
|
|
const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500));
|
|
const startedAt = now();
|
|
const deadline = startedAt + timeoutSec * 1000;
|
|
let lastError = "service did not become ready";
|
|
let lastCause: unknown;
|
|
let probes = 0;
|
|
while (now() < deadline) {
|
|
const probeBudgetMs = Math.max(1, Math.min(RUNTIME_SERVICE_READINESS_PROBE_TIMEOUT_MS, deadline - now()));
|
|
probes += 1;
|
|
try {
|
|
const response = await fetchImpl(readinessUrl, { signal: AbortSignal.timeout(probeBudgetMs) });
|
|
if (response.ok) return;
|
|
lastError = `received HTTP ${response.status}`;
|
|
lastCause = undefined;
|
|
} catch (err) {
|
|
lastCause = err;
|
|
lastError = err instanceof Error ? err.message : String(err);
|
|
// Node fetch hides connection errors behind "fetch failed". Retain the
|
|
// transport cause so a refused port is distinguishable from a timeout.
|
|
if (err instanceof Error && err.cause instanceof Error) {
|
|
lastError += `: ${err.cause.message}`;
|
|
}
|
|
}
|
|
if (now() >= deadline) break;
|
|
await delay(Math.min(intervalMs, Math.max(0, deadline - now())));
|
|
}
|
|
throw new Error(
|
|
`Readiness check failed for ${readinessUrl}: ${lastError} (${probes} probes over ${now() - startedAt}ms)`,
|
|
{ cause: lastCause },
|
|
);
|
|
}
|
|
|
|
async function waitForAllocatedPortBind(input: {
|
|
service: Record<string, unknown>;
|
|
port: number;
|
|
child: ChildProcess;
|
|
}) {
|
|
const deadline = Date.now() + resolveWorkspaceRuntimeReadinessTimeoutSec(input.service) * 1000;
|
|
while (Date.now() < deadline) {
|
|
if (input.child.exitCode !== null || input.child.signalCode !== null) {
|
|
throw new Error("service process exited before binding its allocated port");
|
|
}
|
|
|
|
const ownerPid = await readLocalServicePortOwner(input.port);
|
|
if (ownerPid) {
|
|
const childPid = input.child.pid ?? null;
|
|
if (!childPid || !(await isLocalServiceProcessOwnedBy(ownerPid, childPid))) {
|
|
throw new RuntimeServicePortBindCollision(input.port);
|
|
}
|
|
// Require the same launched process group to retain ownership across a stability delay.
|
|
// Cwd matching alone is insufficient because sibling services can share a workspace.
|
|
await delay(250);
|
|
if (input.child.exitCode !== null || input.child.signalCode !== null) {
|
|
throw new Error("service process exited after losing its allocated port");
|
|
}
|
|
const stableOwnerPid = await readLocalServicePortOwner(input.port);
|
|
if (!stableOwnerPid || !(await isLocalServiceProcessOwnedBy(stableOwnerPid, childPid))) {
|
|
throw new RuntimeServicePortBindCollision(input.port);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// A present listener proves only that some listener appeared. If listener ownership cannot
|
|
// be attributed to this child after a stability delay, retry instead of accepting a sibling.
|
|
// The presence read never binds the port, so it cannot steal the port from the child.
|
|
if (await hasLoopbackPortListener(input.port)) {
|
|
await delay(250);
|
|
if (input.child.exitCode !== null || input.child.signalCode !== null) {
|
|
throw new Error("service process exited after losing its allocated port");
|
|
}
|
|
const stableOwnerPid = await readLocalServicePortOwner(input.port);
|
|
const childPid = input.child.pid ?? null;
|
|
if (stableOwnerPid && childPid && await isLocalServiceProcessOwnedBy(stableOwnerPid, childPid)) return;
|
|
throw new RuntimeServicePortBindCollision(input.port);
|
|
}
|
|
await delay(50);
|
|
}
|
|
throw new Error(`Runtime service did not bind allocated port ${input.port} before timeout`);
|
|
}
|
|
|
|
function isPaperclipDevRuntimeService(input: { serviceName?: string | null; command?: string | null }) {
|
|
const serviceName = (input.serviceName ?? "").trim().toLowerCase();
|
|
const command = (input.command ?? "").trim().toLowerCase();
|
|
return (
|
|
serviceName === "paperclip-dev"
|
|
|| serviceName === "paperclip-dev-once"
|
|
|| (command.includes("dev:once") && command.includes("tailscale-auth"))
|
|
);
|
|
}
|
|
|
|
export const MANAGED_RUNTIME_PUBLIC_URL_ENV = "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL";
|
|
|
|
const EXPLICIT_RUNTIME_ORIGIN_ENV_KEYS = [
|
|
"PAPERCLIP_PUBLIC_URL",
|
|
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
|
|
"BETTER_AUTH_URL",
|
|
"BETTER_AUTH_BASE_URL",
|
|
] as const;
|
|
|
|
function isLoopbackRuntimeHostname(hostname: string) {
|
|
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1") return true;
|
|
if (net.isIP(normalized) !== 4) return false;
|
|
const firstOctet = Number(normalized.split(".")[0]);
|
|
return firstOctet === 127;
|
|
}
|
|
|
|
function managedRuntimeOriginError(serviceName: string, reason: string) {
|
|
return new Error(
|
|
`Runtime service "${serviceName}" cannot derive a browser-reachable OAuth callback origin: ${reason}. `
|
|
+ "Configure PAPERCLIP_PUBLIC_URL or BETTER_AUTH_URL for this service, or publish an HTTPS expose.urlTemplate "
|
|
+ "that the operator's browser can reach (loopback HTTP is also supported).",
|
|
);
|
|
}
|
|
|
|
type TrustedRuntimeHostnameBoundary =
|
|
| { exactHostname: string; hostnameSuffix?: never }
|
|
| { exactHostname?: never; hostnameSuffix: string };
|
|
|
|
function trustedRuntimeHostnameBoundary(
|
|
urlTemplate: string | null | undefined,
|
|
): TrustedRuntimeHostnameBoundary | null {
|
|
if (!urlTemplate?.trim()) return null;
|
|
let markerIndex = 0;
|
|
const markerPrefix = "paperclip-runtime-template-";
|
|
const safeTemplate = urlTemplate.replace(
|
|
/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g,
|
|
(_match, path: string) => path === "port" ? "443" : `${markerPrefix}${markerIndex++}`,
|
|
);
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(safeTemplate);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (parsed.username || parsed.password) return null;
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
|
|
const hostname = parsed.hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
const markers = [...hostname.matchAll(/paperclip-runtime-template-\d+/g)];
|
|
const lastMarker = markers.at(-1);
|
|
if (!lastMarker || lastMarker.index === undefined) {
|
|
return hostname ? { exactHostname: hostname } : null;
|
|
}
|
|
|
|
const hostnameSuffix = hostname.slice(lastMarker.index + lastMarker[0].length);
|
|
// A dynamic non-loopback hostname needs at least a stable two-label domain
|
|
// after the final interpolation. This binds rendered branch/workspace values
|
|
// to the operator-configured domain instead of trusting URL parsing alone.
|
|
if (!hostnameSuffix.startsWith(".") || !hostnameSuffix.slice(1).includes(".")) return null;
|
|
return { hostnameSuffix };
|
|
}
|
|
|
|
/**
|
|
* Resolve the low-priority public URL hint injected into a managed Paperclip dev
|
|
* service. Explicit operator origin settings are deliberately left untouched.
|
|
*/
|
|
export function resolveManagedPaperclipRuntimePublicOrigin(input: {
|
|
serviceName: string;
|
|
command: string;
|
|
environment: Record<string, string>;
|
|
exposedUrl: string | null;
|
|
exposedUrlTemplate?: string | null;
|
|
}) {
|
|
if (!isPaperclipDevRuntimeService(input)) return null;
|
|
if (EXPLICIT_RUNTIME_ORIGIN_ENV_KEYS.some((key) => input.environment[key]?.trim())) return null;
|
|
if (!input.exposedUrl) {
|
|
throw managedRuntimeOriginError(input.serviceName, "the managed service does not report an exposed URL");
|
|
}
|
|
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(input.exposedUrl);
|
|
} catch {
|
|
throw managedRuntimeOriginError(input.serviceName, "the managed service reports an invalid exposed URL");
|
|
}
|
|
|
|
if (parsed.username || parsed.password) {
|
|
throw managedRuntimeOriginError(input.serviceName, "the exposed URL contains credentials");
|
|
}
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
throw managedRuntimeOriginError(input.serviceName, "the exposed URL must use HTTP or HTTPS");
|
|
}
|
|
|
|
const hostname = parsed.hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
const loopback = isLoopbackRuntimeHostname(hostname);
|
|
if (!hostname || hostname === "0.0.0.0" || hostname === "::") {
|
|
throw managedRuntimeOriginError(input.serviceName, "the exposed URL uses a bind-only hostname");
|
|
}
|
|
if (
|
|
!loopback
|
|
&& (
|
|
(!hostname.includes(".") && net.isIP(hostname) === 0)
|
|
|| hostname.endsWith(".invalid")
|
|
|| hostname.endsWith(".test")
|
|
|| hostname.endsWith(".internal")
|
|
|| hostname.endsWith(".localdomain")
|
|
|| hostname === "example.com"
|
|
|| hostname.endsWith(".example.com")
|
|
|| hostname === "example.net"
|
|
|| hostname.endsWith(".example.net")
|
|
|| hostname === "example.org"
|
|
|| hostname.endsWith(".example.org")
|
|
)
|
|
) {
|
|
throw managedRuntimeOriginError(
|
|
input.serviceName,
|
|
`the exposed hostname "${hostname}" is internal-only or non-resolvable from a normal browser`,
|
|
);
|
|
}
|
|
if (!loopback && parsed.protocol !== "https:") {
|
|
throw managedRuntimeOriginError(input.serviceName, "non-loopback OAuth callbacks require HTTPS");
|
|
}
|
|
if (!loopback) {
|
|
const boundary = trustedRuntimeHostnameBoundary(input.exposedUrlTemplate);
|
|
if (!boundary) {
|
|
throw managedRuntimeOriginError(
|
|
input.serviceName,
|
|
"the exposed URL template does not define a stable hostname boundary",
|
|
);
|
|
}
|
|
const withinBoundary = "exactHostname" in boundary
|
|
? hostname === boundary.exactHostname
|
|
: hostname.length > boundary.hostnameSuffix.length && hostname.endsWith(boundary.hostnameSuffix);
|
|
if (!withinBoundary) {
|
|
throw managedRuntimeOriginError(
|
|
input.serviceName,
|
|
`the exposed hostname "${hostname}" is outside the hostname boundary configured by expose.urlTemplate`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return parsed.origin;
|
|
}
|
|
|
|
function resolveRuntimeServiceHealthUrl(
|
|
url: string | null,
|
|
input?: { serviceName?: string | null; command?: string | null },
|
|
) {
|
|
if (!url || !isPaperclipDevRuntimeService(input ?? {})) return url;
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.pathname === "/" || parsed.pathname === "") {
|
|
parsed.pathname = "/api/health";
|
|
parsed.search = "";
|
|
parsed.hash = "";
|
|
return parsed.toString();
|
|
}
|
|
} catch {
|
|
return url;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
type RuntimeServiceHealthProbeInput = {
|
|
db?: Db;
|
|
serviceName?: string | null;
|
|
command?: string | null;
|
|
provider?: string | null;
|
|
port?: number | null;
|
|
/**
|
|
* Workspace identity, when the caller knows it. Supplying all three upgrades
|
|
* the probe from "the port answered with status ok" to the full protected
|
|
* readiness contract, which is what stops a relocated port or a half-restored
|
|
* clone from masquerading as healthy (PAP-17572).
|
|
*/
|
|
cwd?: string | null;
|
|
executionWorkspaceId?: string | null;
|
|
companyId?: string | null;
|
|
};
|
|
|
|
/**
|
|
* Whether a managed workspace runtime satisfies the *user* readiness contract.
|
|
*
|
|
* Returns null when this service is not an identity-resolvable managed workspace
|
|
* runtime, so non-workspace services keep their existing behavior.
|
|
*
|
|
* For a workspace runtime this *replaces* the semantic transport check rather
|
|
* than adding to it. The probe reads the same `/api/health` response the legacy
|
|
* check read, so every legacy verdict is already implied: reaching
|
|
* `readiness_missing` means the response was `200` with `status: ok` (exactly
|
|
* what the legacy check asserted), and every other rejection means it was not.
|
|
* Stacking a second request on top would double the latency of every reuse
|
|
* decision for no extra information.
|
|
*/
|
|
async function probeManagedWorkspaceRuntimeReadiness(
|
|
healthUrl: string,
|
|
input: RuntimeServiceHealthProbeInput,
|
|
): Promise<boolean | null> {
|
|
if (!isPaperclipDevRuntimeService(input)) return null;
|
|
const identity = resolveManagedWorkspaceIdentity({
|
|
workspaceCwd: input.cwd ?? null,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
companyId: input.companyId ?? null,
|
|
});
|
|
if (!identity) return null;
|
|
|
|
const result = await probeManagedWorkspaceReadiness({ healthUrl, identity });
|
|
const verified = !result.ok
|
|
? result
|
|
: input.db
|
|
? await probeManagedWorkspaceHandoffSubjects({ db: input.db, healthUrl, identity })
|
|
: {
|
|
ok: false as const,
|
|
reason: "not_ready" as const,
|
|
readiness: result.readiness,
|
|
detail: "control-plane database is unavailable for board identity verification",
|
|
};
|
|
if (verified.ok) return true;
|
|
logManagedWorkspaceReadinessRejection({
|
|
executionWorkspaceId: identity.executionWorkspaceId,
|
|
healthUrl,
|
|
result: verified,
|
|
});
|
|
// A guest that does not implement the readiness contract yet is not evidence of
|
|
// an unhealthy clone; it is only evidence that the contract cannot be checked.
|
|
return !shouldBlockPublicationOnReadiness(verified);
|
|
}
|
|
|
|
async function isRuntimeServiceUrlHealthy(
|
|
url: string | null,
|
|
input?: RuntimeServiceHealthProbeInput,
|
|
) {
|
|
const localProbeUrl = input?.provider === "local_process" && input.port && isPaperclipDevRuntimeService(input)
|
|
? `http://127.0.0.1:${input.port}`
|
|
: null;
|
|
const probeUrl = localProbeUrl ?? url;
|
|
if (!probeUrl) return true;
|
|
const healthUrl = resolveRuntimeServiceHealthUrl(probeUrl, input);
|
|
if (!healthUrl) return false;
|
|
|
|
const readiness = await probeManagedWorkspaceRuntimeReadiness(healthUrl, input ?? {});
|
|
if (readiness !== null) return readiness;
|
|
|
|
try {
|
|
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) });
|
|
if (!response.ok) return false;
|
|
if (!isPaperclipDevRuntimeService(input ?? {})) return true;
|
|
const payload = await response.json().catch(() => null) as { status?: unknown } | null;
|
|
return payload?.status === "ok";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
|
|
return {
|
|
id: record.id,
|
|
companyId: record.companyId,
|
|
projectId: record.projectId,
|
|
projectWorkspaceId: record.projectWorkspaceId,
|
|
executionWorkspaceId: record.executionWorkspaceId,
|
|
issueId: record.issueId,
|
|
scopeType: record.scopeType,
|
|
scopeId: record.scopeId,
|
|
serviceName: record.serviceName,
|
|
status: record.status,
|
|
lifecycle: record.lifecycle,
|
|
reuseKey: record.reuseKey,
|
|
command: record.command,
|
|
cwd: record.cwd,
|
|
port: record.port,
|
|
url: record.url,
|
|
provider: record.provider,
|
|
providerRef: record.providerRef,
|
|
ownerAgentId: record.ownerAgentId,
|
|
startedByRunId: record.startedByRunId,
|
|
lastUsedAt: new Date(record.lastUsedAt),
|
|
startedAt: new Date(record.startedAt),
|
|
stoppedAt: record.stoppedAt ? new Date(record.stoppedAt) : null,
|
|
stopPolicy: record.stopPolicy,
|
|
exposure: record.exposure,
|
|
exposureHandle: record.exposureHandle,
|
|
backendUrl: record.backendUrl,
|
|
healthStatus: record.healthStatus,
|
|
updatedAt: new Date(),
|
|
};
|
|
}
|
|
|
|
async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeServiceRecord) {
|
|
if (!db) return;
|
|
const values = toPersistedWorkspaceRuntimeService(record);
|
|
await db
|
|
.insert(workspaceRuntimeServices)
|
|
.values(values)
|
|
.onConflictDoUpdate({
|
|
target: workspaceRuntimeServices.id,
|
|
set: {
|
|
projectId: values.projectId,
|
|
projectWorkspaceId: values.projectWorkspaceId,
|
|
executionWorkspaceId: values.executionWorkspaceId,
|
|
issueId: values.issueId,
|
|
scopeType: values.scopeType,
|
|
scopeId: values.scopeId,
|
|
serviceName: values.serviceName,
|
|
status: values.status,
|
|
lifecycle: values.lifecycle,
|
|
reuseKey: values.reuseKey,
|
|
command: values.command,
|
|
cwd: values.cwd,
|
|
port: values.port,
|
|
url: values.url,
|
|
provider: values.provider,
|
|
providerRef: values.providerRef,
|
|
ownerAgentId: values.ownerAgentId,
|
|
startedByRunId: values.startedByRunId,
|
|
lastUsedAt: values.lastUsedAt,
|
|
startedAt: values.startedAt,
|
|
stoppedAt: values.stoppedAt,
|
|
stopPolicy: values.stopPolicy,
|
|
exposure: values.exposure,
|
|
exposureHandle: values.exposureHandle,
|
|
backendUrl: values.backendUrl,
|
|
healthStatus: values.healthStatus,
|
|
updatedAt: values.updatedAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function findStoppedRuntimeServiceReuseCandidate(input: {
|
|
db?: Db;
|
|
companyId: string;
|
|
reuseKey: string | null;
|
|
serviceName: string;
|
|
command: string;
|
|
cwd: string;
|
|
scopeType: RuntimeServiceRef["scopeType"];
|
|
scopeId: string | null;
|
|
}): Promise<StoppedRuntimeServiceReuseCandidate | null> {
|
|
if (!input.db) return null;
|
|
if (input.reuseKey) {
|
|
const row = await input.db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
port: workspaceRuntimeServices.port,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(
|
|
and(
|
|
eq(workspaceRuntimeServices.companyId, input.companyId),
|
|
eq(workspaceRuntimeServices.reuseKey, input.reuseKey),
|
|
eq(workspaceRuntimeServices.provider, "local_process"),
|
|
eq(workspaceRuntimeServices.status, "stopped"),
|
|
),
|
|
)
|
|
.orderBy(desc(workspaceRuntimeServices.updatedAt))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (row) return row;
|
|
}
|
|
|
|
const scopeIdCondition = input.scopeId === null
|
|
? isNull(workspaceRuntimeServices.scopeId)
|
|
: eq(workspaceRuntimeServices.scopeId, input.scopeId);
|
|
const row = await input.db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
port: workspaceRuntimeServices.port,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(
|
|
and(
|
|
eq(workspaceRuntimeServices.companyId, input.companyId),
|
|
eq(workspaceRuntimeServices.provider, "local_process"),
|
|
eq(workspaceRuntimeServices.status, "stopped"),
|
|
eq(workspaceRuntimeServices.scopeType, input.scopeType),
|
|
scopeIdCondition,
|
|
eq(workspaceRuntimeServices.serviceName, input.serviceName),
|
|
eq(workspaceRuntimeServices.command, input.command),
|
|
eq(workspaceRuntimeServices.cwd, input.cwd),
|
|
),
|
|
)
|
|
.orderBy(desc(workspaceRuntimeServices.updatedAt))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
return row ?? null;
|
|
}
|
|
|
|
function clearIdleTimer(record: RuntimeServiceRecord) {
|
|
if (!record.idleTimer) return;
|
|
clearTimeout(record.idleTimer);
|
|
record.idleTimer = null;
|
|
}
|
|
|
|
export function normalizeAdapterManagedRuntimeServices(input: {
|
|
adapterType: string;
|
|
runId: string;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
workspace: RealizedExecutionWorkspace;
|
|
executionWorkspaceId?: string | null;
|
|
reports: AdapterRuntimeServiceReport[];
|
|
now?: Date;
|
|
}): RuntimeServiceRef[] {
|
|
const nowIso = (input.now ?? new Date()).toISOString();
|
|
return input.reports.map((report) => {
|
|
const scopeType = report.scopeType ?? "run";
|
|
const scopeId =
|
|
report.scopeId ??
|
|
(scopeType === "project_workspace"
|
|
? input.workspace.workspaceId
|
|
: scopeType === "execution_workspace"
|
|
? input.executionWorkspaceId ?? input.workspace.cwd
|
|
: scopeType === "agent"
|
|
? input.agent.id
|
|
: input.runId) ??
|
|
null;
|
|
const serviceName = asString(report.serviceName, "").trim() || "service";
|
|
const status = report.status ?? "running";
|
|
const lifecycle = report.lifecycle ?? "ephemeral";
|
|
const healthStatus =
|
|
report.healthStatus ??
|
|
(status === "running" ? "healthy" : status === "failed" ? "unhealthy" : "unknown");
|
|
return {
|
|
id: stableRuntimeServiceId({
|
|
adapterType: input.adapterType,
|
|
runId: input.runId,
|
|
scopeType,
|
|
scopeId,
|
|
serviceName,
|
|
reportId: report.id ?? null,
|
|
providerRef: report.providerRef ?? null,
|
|
reuseKey: report.reuseKey ?? null,
|
|
}),
|
|
companyId: input.agent.companyId,
|
|
projectId: report.projectId ?? input.workspace.projectId,
|
|
projectWorkspaceId: report.projectWorkspaceId ?? input.workspace.workspaceId,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
issueId: report.issueId ?? input.issue?.id ?? null,
|
|
serviceName,
|
|
status,
|
|
lifecycle,
|
|
scopeType,
|
|
scopeId,
|
|
reuseKey: report.reuseKey ?? null,
|
|
command: report.command ?? null,
|
|
cwd: report.cwd ?? null,
|
|
port: report.port ?? null,
|
|
url: report.url ?? null,
|
|
provider: "adapter_managed",
|
|
providerRef: report.providerRef ?? null,
|
|
ownerAgentId: report.ownerAgentId ?? input.agent.id ?? null,
|
|
startedByRunId: input.runId,
|
|
lastUsedAt: nowIso,
|
|
startedAt: nowIso,
|
|
stoppedAt: status === "running" || status === "starting" ? null : nowIso,
|
|
stopPolicy: report.stopPolicy ?? null,
|
|
healthStatus,
|
|
exposure: null,
|
|
reused: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
type StartLocalRuntimeServiceInput = {
|
|
db?: Db;
|
|
runId: string;
|
|
leaseRunId?: string | null;
|
|
startedByRunId?: string | null;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
workspace: RealizedExecutionWorkspace;
|
|
executionWorkspaceId?: string | null;
|
|
adapterEnv: Record<string, string>;
|
|
service: Record<string, unknown>;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
runtimeProvisionCommand?: string | null;
|
|
runtimeProvisionKind?: RuntimeProvisionKind | null;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
provisionCoordinator?: RuntimeProvisionCoordinator;
|
|
preparedProvisioningRecord?: RuntimeServiceRecord | null;
|
|
runtimeServiceId?: string;
|
|
allowFixedPortFallback?: boolean;
|
|
excludedPorts?: ReadonlySet<number>;
|
|
reuseKey: string | null;
|
|
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
|
scopeId: string | null;
|
|
};
|
|
|
|
type RuntimeProvisionCoordinator = {
|
|
promise: Promise<void> | null;
|
|
};
|
|
|
|
function createRuntimeProvisionCoordinator(): RuntimeProvisionCoordinator {
|
|
return { promise: null };
|
|
}
|
|
|
|
function readRuntimeProvisionCommand(config: Record<string, unknown>) {
|
|
const workspaceStrategy = parseObject(config.workspaceStrategy);
|
|
return asString(
|
|
config.runtimeProvisionCommand,
|
|
asString(workspaceStrategy.runtimeProvisionCommand, ""),
|
|
).trim();
|
|
}
|
|
|
|
const BUILTIN_WORKSPACE_SEED_COMMAND = "bash ./scripts/provision-worktree-runtime.sh";
|
|
|
|
type RuntimeProvisionKind = "workspace_seed" | "runtime_dependencies";
|
|
|
|
function readWorkspaceSeedOperationEvidence(worktreePath: string): {
|
|
verified: boolean;
|
|
error: string | null;
|
|
metadata: Record<string, unknown>;
|
|
} {
|
|
const manifestPath = path.join(worktreePath, ".paperclip", "seed-manifest.json");
|
|
try {
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
|
|
const state = typeof manifest.state === "string" ? manifest.state : "unknown";
|
|
const phase = typeof manifest.phase === "string" ? manifest.phase : null;
|
|
const verified = isVerifiedWorktreeSeedManifest(manifest);
|
|
return {
|
|
verified,
|
|
error: verified
|
|
? null
|
|
: phase
|
|
? `Workspace seed command returned without a verified manifest (state: ${state}, phase: ${phase}).`
|
|
: `Workspace seed command returned without a verified manifest (state: ${state}).`,
|
|
metadata: {
|
|
provisionKind: "workspace_seed",
|
|
seedState: state,
|
|
seedPhase: phase,
|
|
seedFailurePhase: state === "failed" ? phase : null,
|
|
},
|
|
};
|
|
} catch {
|
|
return {
|
|
verified: false,
|
|
error: "Workspace seed command returned without a readable seed manifest.",
|
|
metadata: {
|
|
provisionKind: "workspace_seed",
|
|
seedState: existsSync(manifestPath) ? "unreadable" : "absent",
|
|
seedPhase: null,
|
|
seedFailurePhase: "seed_manifest_unreadable",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export function resolveRuntimeProvisionCommand(input: {
|
|
config: Record<string, unknown>;
|
|
workspace: RealizedExecutionWorkspace;
|
|
}) {
|
|
const configuredCommand = readRuntimeProvisionCommand(input.config);
|
|
if (configuredCommand) return configuredCommand;
|
|
|
|
if (input.workspace.strategy !== "git_worktree") return "";
|
|
|
|
const stateDir = path.join(input.workspace.cwd, ".paperclip");
|
|
if (existsSync(path.join(stateDir, "seed-empty"))) return "";
|
|
const manifestPath = path.join(stateDir, "seed-manifest.json");
|
|
const provisionScript = path.join(
|
|
input.workspace.baseCwd,
|
|
"scripts",
|
|
"provision-worktree-runtime.sh",
|
|
);
|
|
const needsSeed = !existsSync(manifestPath) || !hasVerifiedWorktreeSeedManifest(manifestPath);
|
|
if (!needsSeed || !existsSync(provisionScript)) {
|
|
return "";
|
|
}
|
|
|
|
return BUILTIN_WORKSPACE_SEED_COMMAND;
|
|
}
|
|
|
|
function resolveRuntimeProvision(input: {
|
|
config: Record<string, unknown>;
|
|
workspace: RealizedExecutionWorkspace;
|
|
}): { command: string; kind: RuntimeProvisionKind | null } {
|
|
const command = resolveRuntimeProvisionCommand(input);
|
|
if (!command) return { command, kind: null };
|
|
return {
|
|
command,
|
|
kind: readRuntimeProvisionCommand(input.config)
|
|
? "runtime_dependencies"
|
|
: "workspace_seed",
|
|
};
|
|
}
|
|
|
|
function runtimeProvisionWorkspaceKey(input: StartLocalRuntimeServiceInput) {
|
|
return input.executionWorkspaceId
|
|
? `execution-workspace:${input.executionWorkspaceId}`
|
|
: input.workspace.workspaceId
|
|
? `project-workspace:${input.workspace.workspaceId}`
|
|
: `cwd:${path.resolve(input.workspace.cwd)}`;
|
|
}
|
|
|
|
async function runRuntimeProvisionWithWorkspaceMutex(input: StartLocalRuntimeServiceInput) {
|
|
const command = asString(input.runtimeProvisionCommand, "").trim();
|
|
if (!command) return;
|
|
|
|
const workspaceKey = runtimeProvisionWorkspaceKey(input);
|
|
const existing = runtimeProvisionByWorkspace.get(workspaceKey);
|
|
if (existing) {
|
|
await existing;
|
|
return;
|
|
}
|
|
|
|
const recorder = input.recorder ?? (input.db
|
|
? workspaceOperationService(input.db).createRecorder({
|
|
companyId: input.agent.companyId,
|
|
heartbeatRunId: input.startedByRunId === undefined ? input.runId : input.startedByRunId,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
issueId: input.issue?.id ?? null,
|
|
})
|
|
: null);
|
|
const resolvedCommand = resolveRepoManagedWorkspaceCommand(command, input.workspace.baseCwd);
|
|
const workspaceSeed = input.runtimeProvisionKind === "workspace_seed";
|
|
const promise = recordWorkspaceCommandOperation(recorder, {
|
|
phase: workspaceSeed ? "workspace_seed" : "workspace_runtime_provision",
|
|
command,
|
|
resolvedCommand,
|
|
cwd: input.workspace.cwd,
|
|
env: buildWorkspaceCommandEnv({
|
|
base: input.workspace,
|
|
repoRoot: input.workspace.baseCwd,
|
|
worktreePath: input.workspace.cwd,
|
|
branchName: input.workspace.branchName ?? "",
|
|
issue: input.issue,
|
|
agent: input.agent,
|
|
created: input.workspace.created,
|
|
}),
|
|
label: workspaceSeed
|
|
? `Workspace seed command "${command}"`
|
|
: `Runtime provision command "${command}"`,
|
|
metadata: {
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
projectWorkspaceId: input.workspace.workspaceId,
|
|
serviceName: asString(input.service.name, "service"),
|
|
provisionKind: workspaceSeed ? "workspace_seed" : "runtime_dependencies",
|
|
resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
|
|
},
|
|
successMessage: workspaceSeed
|
|
? `Verified the workspace database seed for ${input.workspace.cwd}\n`
|
|
: `Provisioned runtime dependencies for ${input.workspace.cwd}\n`,
|
|
onLog: input.onLog,
|
|
}).then(() => undefined);
|
|
|
|
runtimeProvisionByWorkspace.set(workspaceKey, promise);
|
|
try {
|
|
await promise;
|
|
} finally {
|
|
if (runtimeProvisionByWorkspace.get(workspaceKey) === promise) {
|
|
runtimeProvisionByWorkspace.delete(workspaceKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createProvisioningRuntimeServiceRecord(
|
|
input: StartLocalRuntimeServiceInput,
|
|
identity: ReturnType<typeof resolveRuntimeServiceReuseIdentity>,
|
|
): RuntimeServiceRecord {
|
|
const nowIso = new Date().toISOString();
|
|
const id = input.runtimeServiceId ?? randomUUID();
|
|
return {
|
|
id,
|
|
companyId: input.agent.companyId,
|
|
projectId: input.workspace.projectId,
|
|
projectWorkspaceId: input.workspace.workspaceId,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
issueId: input.issue?.id ?? null,
|
|
serviceName: identity.serviceName,
|
|
status: "provisioning",
|
|
lifecycle: identity.lifecycle,
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
reuseKey: input.reuseKey,
|
|
command: identity.command,
|
|
cwd: identity.serviceCwd,
|
|
port: identity.identityPort,
|
|
url: null,
|
|
provider: "local_process",
|
|
providerRef: null,
|
|
ownerAgentId: input.agent.id ?? null,
|
|
startedByRunId: input.startedByRunId === undefined ? input.runId : input.startedByRunId,
|
|
lastUsedAt: nowIso,
|
|
startedAt: nowIso,
|
|
stoppedAt: null,
|
|
stopPolicy: parseObject(input.service.stopPolicy),
|
|
healthStatus: "unknown",
|
|
exposure: null,
|
|
reused: false,
|
|
db: input.db,
|
|
child: null,
|
|
leaseRunIds: new Set(),
|
|
idleTimer: null,
|
|
envFingerprint: identity.envFingerprint,
|
|
serviceKey: `runtime-provision:${runtimeProvisionWorkspaceKey(input)}:${id}`,
|
|
profileKind: "workspace-runtime",
|
|
processGroupId: null,
|
|
exposureHandle: null,
|
|
backendUrl: null,
|
|
exposureConfig: null,
|
|
};
|
|
}
|
|
|
|
async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): Promise<LocalRuntimeServiceStart> {
|
|
const leaseRunId = input.leaseRunId === undefined ? input.runId : input.leaseRunId;
|
|
const startedByRunId = input.startedByRunId === undefined ? input.runId : input.startedByRunId;
|
|
const identity = resolveRuntimeServiceReuseIdentity({
|
|
service: input.service,
|
|
workspace: input.workspace,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
});
|
|
const serviceName = identity.serviceName;
|
|
const lifecycle = identity.lifecycle;
|
|
const declaredCommand = identity.command;
|
|
if (!declaredCommand) throw new Error(`Runtime service "${serviceName}" is missing command`);
|
|
const portConfig = parseObject(input.service.port);
|
|
const envConfig = identity.envConfig;
|
|
const envFingerprint = identity.envFingerprint;
|
|
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
|
|
const explicitPort = identity.explicitPort;
|
|
const identityPort = identity.identityPort;
|
|
const resolvedExposure = await resolveRuntimeServiceExposure({
|
|
service: input.service,
|
|
serviceName,
|
|
command: declaredCommand,
|
|
});
|
|
const exposureConfig = resolvedExposure?.config ?? null;
|
|
// An exposed listener MUST be loopback-only or the broker denies it. Env vars
|
|
// alone cannot guarantee that: the process that has to honour them is the
|
|
// *guest checkout's* dev runner, and one from before managed exposure existed
|
|
// overwrites PAPERCLIP_BIND from its own `--bind` argv and deletes
|
|
// PAPERCLIP_BIND_HOST — which is exactly how a branch pinned at plain master
|
|
// bound 0.0.0.0 and failed every start (PAP-17256). argv is honoured by every
|
|
// dev-runner version, so put the loopback bind there.
|
|
//
|
|
// Scoped by `forceLoopbackBindInCommand` to commands that actually parse these
|
|
// flags: `--bind` means something else entirely to an unrelated service (the
|
|
// HTTPS probe canaries pass it to `python3 -m http.server`), and appending
|
|
// flags a command cannot parse would make it exit on startup.
|
|
const command = exposureConfig ? forceLoopbackBindInCommand(declaredCommand) : declaredCommand;
|
|
const portType = asString(portConfig.type, "");
|
|
const canAllocateFixedPort = Boolean(
|
|
!exposureConfig
|
|
&& input.allowFixedPortFallback
|
|
&& input.db
|
|
&& input.executionWorkspaceId
|
|
&& explicitPort > 0,
|
|
);
|
|
const stoppedReuseCandidate = await findStoppedRuntimeServiceReuseCandidate({
|
|
db: input.db,
|
|
companyId: input.agent.companyId,
|
|
reuseKey: input.reuseKey,
|
|
serviceName,
|
|
command,
|
|
cwd: identity.serviceCwd,
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
});
|
|
let fixedPortRegistryMatch = false;
|
|
if (!exposureConfig && canAllocateFixedPort && identityPort) {
|
|
const identityTemplateData = buildTemplateData({
|
|
workspace: input.workspace,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
port: identityPort,
|
|
});
|
|
const identityExpose = parseObject(input.service.expose);
|
|
const identityReadiness = parseObject(input.service.readiness);
|
|
const identityUrlTemplate =
|
|
asString(identityExpose.urlTemplate, "")
|
|
|| asString(identityReadiness.urlTemplate, "");
|
|
const identityBackendUrl = identityUrlTemplate
|
|
? renderTemplate(identityUrlTemplate, identityTemplateData)
|
|
: null;
|
|
const identityServiceKey = createLocalServiceKey({
|
|
profileKind: "workspace-runtime",
|
|
serviceName,
|
|
cwd: identity.serviceCwd,
|
|
command,
|
|
envFingerprint: serviceIdentityFingerprint,
|
|
port: identityPort,
|
|
scope: {
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
reuseKey: input.reuseKey,
|
|
},
|
|
});
|
|
fixedPortRegistryMatch = Boolean(await findAdoptableLocalService({
|
|
serviceKey: identityServiceKey,
|
|
profileKind: "workspace-runtime",
|
|
serviceName,
|
|
command,
|
|
cwd: identity.serviceCwd,
|
|
envFingerprint: serviceIdentityFingerprint,
|
|
port: identityPort,
|
|
url: identityBackendUrl,
|
|
}));
|
|
}
|
|
const runtimeId = input.runtimeServiceId ?? stoppedReuseCandidate?.id ?? randomUUID();
|
|
// An exposed runtime always takes its port from the dedicated broker range, so
|
|
// a configured or previously used port is a *preference*, not a constraint. It
|
|
// is honored when it is already an allowlisted app port whose HMR companion is
|
|
// free — that keeps a restart on the same port and keeps a backfilled service
|
|
// stable across deploys — and quietly relocated when it is not, which is the
|
|
// only way a legacy pinned port (the Paperclip App template's 45439) can be
|
|
// published at all. If the backend then fails to listen where we allocated,
|
|
// the broker's /proc ownership proof refuses the mapping and the start fails
|
|
// closed; it never falls back to HTTP.
|
|
const reservedExposure = exposureConfig
|
|
? await allocateAndReserveExposure({
|
|
db: input.db,
|
|
companyId: input.agent.companyId,
|
|
runtimeId,
|
|
config: exposureConfig,
|
|
claimant: {
|
|
runtimeServiceId: runtimeId,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
projectWorkspaceId: input.workspace.workspaceId,
|
|
issueId: input.issue?.id ?? null,
|
|
},
|
|
preferredAppPort: stoppedReuseCandidate?.port ?? (explicitPort > 0 ? explicitPort : null),
|
|
})
|
|
: null;
|
|
// Loopback port this start claimed in-process for its own duration (PAP-17249). A bare
|
|
// `listen(0)` probe is closed before the child binds, so the kernel is free to hand the same
|
|
// candidate to a sibling start; holding the claim until the child owns the port — or until the
|
|
// start reaches a terminal state — is what keeps two concurrent lanes distinct. Exposed
|
|
// runtimes carry no in-process claim: their port pair is held by the broker reservation.
|
|
let reservedPort: number | null = null;
|
|
let port: number | null = reservedExposure?.appPort ?? null;
|
|
if (!reservedExposure && portType === "auto") {
|
|
if (
|
|
stoppedReuseCandidate?.port
|
|
&& !input.excludedPorts?.has(stoppedReuseCandidate.port)
|
|
// Reserving the reuse candidate keeps two concurrent starts of the same stopped service
|
|
// from both deciding the old port is free.
|
|
&& reservePortIfFree(stoppedReuseCandidate.port)
|
|
) {
|
|
if (await canBindRuntimePort(stoppedReuseCandidate.port)) {
|
|
port = stoppedReuseCandidate.port;
|
|
reservedPort = stoppedReuseCandidate.port;
|
|
} else {
|
|
releasePortReservation(stoppedReuseCandidate.port);
|
|
}
|
|
}
|
|
for (let attempt = 0; port === null && attempt < WORKSPACE_RUNTIME_PORT_ALLOCATION_ATTEMPTS; attempt += 1) {
|
|
// Reserves its candidate in-process and re-checks it for a live owner before returning.
|
|
const candidate = await allocateRuntimeServicePort();
|
|
if (input.excludedPorts?.has(candidate)) {
|
|
releasePortReservation(candidate);
|
|
continue;
|
|
}
|
|
port = candidate;
|
|
reservedPort = candidate;
|
|
}
|
|
if (port === null) {
|
|
throw conflict("No safe automatically allocated runtime service port is available.", {
|
|
code: "workspace_runtime_port_allocation_exhausted",
|
|
attemptedPortCount: WORKSPACE_RUNTIME_PORT_ALLOCATION_ATTEMPTS,
|
|
remediation: "Retry the start or configure a different runtime service port.",
|
|
});
|
|
}
|
|
} else if (!reservedExposure && canAllocateFixedPort && fixedPortRegistryMatch) {
|
|
port = explicitPort;
|
|
} else if (!reservedExposure && canAllocateFixedPort) {
|
|
port = await allocateIsolatedWorkspacePort({
|
|
db: input.db,
|
|
companyId: input.agent.companyId,
|
|
executionWorkspaceId: input.executionWorkspaceId!,
|
|
preferredPort: explicitPort,
|
|
stoppedPort: stoppedReuseCandidate?.port ?? null,
|
|
excludedPorts: input.excludedPorts ?? new Set<number>(),
|
|
});
|
|
// The bounded fixed-port scan reserves the port it hands back for the same reason.
|
|
reservedPort = port;
|
|
} else if (!reservedExposure) {
|
|
port = explicitPort > 0 ? explicitPort : null;
|
|
}
|
|
let exposureHostname: string | null = null;
|
|
if (reservedExposure) {
|
|
try {
|
|
exposureHostname = await workspaceRuntimeExposureDeps.resolveHostname();
|
|
reservedExposure.status.hostname = exposureHostname;
|
|
reservedExposure.status.updatedAt = new Date().toISOString();
|
|
} catch {
|
|
await workspaceRuntimeExposureDeps.broker
|
|
.remove(runtimeId, reservedExposure.handle)
|
|
.catch(() => undefined);
|
|
// The reservation deliberately keeps the winning pair's in-process claim for
|
|
// the caller to release on stop/teardown. No runtime record exists yet, so
|
|
// that teardown path can never run for this pair — releasing the broker
|
|
// reservation alone would leave the claim held. A hostname outage would then
|
|
// burn one pair per attempt, and a retry storm inside the claim TTL would
|
|
// report the range exhausted rather than the real cause.
|
|
exposurePortPairClaims.release({
|
|
appPort: reservedExposure.appPort,
|
|
hmrPort: reservedExposure.hmrPort,
|
|
});
|
|
throw new Error("HTTPS exposure failed: Tailscale MagicDNS hostname unavailable");
|
|
}
|
|
}
|
|
const templateData = buildTemplateData({
|
|
workspace: input.workspace,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
port,
|
|
});
|
|
const serviceCwd =
|
|
port === identityPort
|
|
? identity.serviceCwd
|
|
: resolveConfiguredPath(renderTemplate(asString(input.service.cwd, "."), templateData), input.workspace.cwd);
|
|
const runtimeEnvOverrides: Record<string, string> = { ...input.adapterEnv };
|
|
for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) {
|
|
runtimeEnvOverrides[key] = value;
|
|
}
|
|
const env: Record<string, string> = {
|
|
...sanitizeRuntimeServiceBaseEnv(process.env),
|
|
...runtimeEnvOverrides,
|
|
} as Record<string, string>;
|
|
// Managed Paperclip worktrees are development environments, so their UI
|
|
// should track source edits without each project repeating this setting.
|
|
// An HTTPS profile must publish the companion HMR listener before it can use
|
|
// this default. Otherwise, leave the value unset so dev-runner keeps its
|
|
// built-UI safeguard. Keep every explicit service/adapter value.
|
|
const uiDevMiddlewareHasTransport =
|
|
!exposureConfig || exposureConfig.includePaperclipViteHmr;
|
|
if (
|
|
uiDevMiddlewareHasTransport
|
|
&& isPaperclipDevRuntimeService({ serviceName, command })
|
|
) {
|
|
env.PAPERCLIP_UI_DEV_MIDDLEWARE ??= "true";
|
|
}
|
|
if (port) {
|
|
const portEnvKey = asString(portConfig.envKey, "PORT");
|
|
env[portEnvKey] = String(port);
|
|
}
|
|
|
|
// Per-workspace handoff key, readiness token, and workspace id. Injected for
|
|
// the Paperclip dev runtime whether or not it is HTTPS-exposed, because the
|
|
// password-independent login handoff and the protected readiness probe are
|
|
// both needed for a plain-HTTP loopback workspace too (PAP-17572).
|
|
const managedWorkspaceIdentity = isPaperclipDevRuntimeService({ serviceName, command })
|
|
? resolveManagedWorkspaceIdentity({
|
|
workspaceCwd: input.workspace.cwd,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
companyId: input.agent.companyId,
|
|
})
|
|
: null;
|
|
if (managedWorkspaceIdentity) {
|
|
Object.assign(env, buildManagedWorkspaceGuestEnv(managedWorkspaceIdentity));
|
|
}
|
|
|
|
if (exposureConfig) {
|
|
// Paperclip dev-runtime-specific hardening. Other managed processes are
|
|
// still rejected by the broker unless /proc proves loopback-only listeners.
|
|
//
|
|
// Three independent layers force the loopback bind, because a guest checkout
|
|
// can be arbitrarily old (PAP-17256): the `--bind loopback` argv
|
|
// added above, these env vars for a runner that reads them, and HOST for one
|
|
// old enough to ignore both and infer its bind mode from HOST alone.
|
|
env.PAPERCLIP_BIND = RUNTIME_EXPOSURE_BIND_MODE;
|
|
env.PAPERCLIP_BIND_HOST = RUNTIME_EXPOSURE_BIND_HOST;
|
|
env.HOST = RUNTIME_EXPOSURE_BIND_HOST;
|
|
env.PAPERCLIP_VITE_HMR_PROTOCOL = "wss";
|
|
env.PAPERCLIP_MANAGED_RUNTIME_EXPOSURE = "tailscale_https";
|
|
env.PAPERCLIP_ALLOWED_HOSTNAMES = exposureHostname!;
|
|
env.PAPERCLIP_AUTH_BASE_URL_MODE = "explicit";
|
|
env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = `https://${exposureHostname}:${port}`;
|
|
env.PAPERCLIP_PUBLIC_URL = `https://${exposureHostname}:${port}`;
|
|
}
|
|
|
|
const expose = parseObject(input.service.expose);
|
|
const readiness = parseObject(input.service.readiness);
|
|
const urlTemplate =
|
|
asString(expose.urlTemplate, "") ||
|
|
asString(readiness.urlTemplate, "");
|
|
const backendUrl = urlTemplate ? renderTemplate(urlTemplate, templateData) : null;
|
|
let url = exposureConfig ? null : backendUrl;
|
|
const readinessUrlTemplate = asString(readiness.urlTemplate, "");
|
|
const readinessUrl = readinessUrlTemplate ? renderTemplate(readinessUrlTemplate, templateData) : null;
|
|
const managedRuntimePublicOrigin = resolveManagedPaperclipRuntimePublicOrigin({
|
|
serviceName,
|
|
command,
|
|
// Includes the trusted public origin injected above for managed HTTPS
|
|
// exposure. The inherited parent environment was already sanitized, so
|
|
// any remaining explicit origin is either service-configured or broker-
|
|
// derived for this exact runtime.
|
|
environment: env,
|
|
exposedUrl: url,
|
|
exposedUrlTemplate: urlTemplate,
|
|
});
|
|
if (managedRuntimePublicOrigin) {
|
|
env[MANAGED_RUNTIME_PUBLIC_URL_ENV] = managedRuntimePublicOrigin;
|
|
}
|
|
const stopPolicy = parseObject(input.service.stopPolicy);
|
|
const serviceKey = createLocalServiceKey({
|
|
profileKind: "workspace-runtime",
|
|
serviceName,
|
|
cwd: serviceCwd,
|
|
command,
|
|
envFingerprint: serviceIdentityFingerprint,
|
|
port: identityPort,
|
|
scope: {
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
reuseKey: input.reuseKey,
|
|
},
|
|
});
|
|
const adoptedRecord = exposureConfig ? null : await findAdoptableLocalService({
|
|
serviceKey,
|
|
profileKind: "workspace-runtime",
|
|
serviceName,
|
|
command,
|
|
cwd: serviceCwd,
|
|
envFingerprint: serviceIdentityFingerprint,
|
|
port: port ?? identityPort,
|
|
url: backendUrl,
|
|
});
|
|
if (adoptedRecord) {
|
|
const adoptedUrl = adoptedRecord.url ?? backendUrl;
|
|
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, {
|
|
db: input.db,
|
|
serviceName,
|
|
command,
|
|
cwd: input.workspace.cwd,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
companyId: input.agent.companyId,
|
|
}))) {
|
|
await terminateLocalService(adoptedRecord);
|
|
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
|
} else {
|
|
releasePortReservation(reservedPort);
|
|
return {
|
|
record: {
|
|
id: adoptedRecord.runtimeServiceId ?? randomUUID(),
|
|
companyId: input.agent.companyId,
|
|
projectId: input.workspace.projectId,
|
|
projectWorkspaceId: input.workspace.workspaceId,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
issueId: input.issue?.id ?? null,
|
|
serviceName,
|
|
status: "running",
|
|
lifecycle,
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
reuseKey: input.reuseKey,
|
|
command,
|
|
cwd: serviceCwd,
|
|
port: adoptedRecord.port ?? port,
|
|
url: adoptedRecord.url ?? url,
|
|
provider: "local_process",
|
|
providerRef: String(adoptedRecord.pid),
|
|
ownerAgentId: input.agent.id ?? null,
|
|
startedByRunId,
|
|
lastUsedAt: new Date().toISOString(),
|
|
startedAt: adoptedRecord.startedAt,
|
|
stoppedAt: null,
|
|
stopPolicy,
|
|
healthStatus: "healthy",
|
|
exposure: null,
|
|
reused: true,
|
|
db: input.db,
|
|
child: null,
|
|
leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(),
|
|
idleTimer: null,
|
|
envFingerprint,
|
|
serviceKey,
|
|
profileKind: "workspace-runtime",
|
|
processGroupId: adoptedRecord.processGroupId ?? null,
|
|
exposureHandle: null,
|
|
backendUrl: adoptedUrl,
|
|
exposureConfig: null,
|
|
},
|
|
readiness: Promise.resolve(),
|
|
};
|
|
}
|
|
}
|
|
// A pinned port is only worth a conflict check when the service will actually
|
|
// bind it. Under HTTPS exposure the port comes from the broker's dedicated
|
|
// range instead, and both ports in that pair were already probed free before
|
|
// the lease was taken — so checking the pinned port here would reject a
|
|
// legacy `port: 45439` service purely because the pre-backfill instance still
|
|
// holds 45439, which is exactly the workspace this feature has to upgrade.
|
|
const conflictPort = reservedExposure ? null : port;
|
|
if (conflictPort) {
|
|
const ownerPid = await readLocalServicePortOwner(conflictPort);
|
|
if (ownerPid) {
|
|
if (canAllocateFixedPort || portType === "auto") {
|
|
throw new RuntimeServicePortBindCollision(conflictPort);
|
|
}
|
|
const ownerCwd = await readLocalServiceProcessCwd(ownerPid);
|
|
const ownerIsInWorkspace = ownerCwd
|
|
? await isLocalServiceProcessInWorkspace(ownerCwd, serviceCwd)
|
|
: null;
|
|
const ownerDescription = ownerCwd ? `pid ${ownerPid} (cwd: ${ownerCwd})` : `pid ${ownerPid} (cwd unavailable)`;
|
|
releasePortReservation(reservedPort);
|
|
if (ownerIsInWorkspace === false) {
|
|
throw new Error(
|
|
`Runtime service "${serviceName}" could not start because port ${conflictPort} has a cross-workspace port conflict with ${ownerDescription}; requested workspace: ${serviceCwd}. Stop the other service or configure a different port.`,
|
|
);
|
|
}
|
|
throw new Error(
|
|
`Runtime service "${serviceName}" could not start because port ${conflictPort} is already in use by ${ownerDescription}`,
|
|
);
|
|
}
|
|
// A configured port that is free right now can still be taken by a sibling workspace that
|
|
// is mid-start. Claiming it in-process makes the loser fail terminally here instead of
|
|
// silently racing to bind and then hanging on a readiness probe it can never satisfy.
|
|
// `conflictPort !== reservedPort` guards the port this start allocated for itself: we
|
|
// must not fail a start by colliding with our own reservation.
|
|
if (!claimRuntimeServiceBindPort(conflictPort, reservedPort)) {
|
|
releasePortReservation(reservedPort);
|
|
throw new Error(
|
|
`Runtime service "${serviceName}" could not start because configured port ${conflictPort} is already being claimed by another managed start in this instance. Retry once that start settles, or configure a different port.`,
|
|
);
|
|
}
|
|
}
|
|
const claimedIdentityPort = conflictPort && conflictPort !== reservedPort ? conflictPort : null;
|
|
|
|
const nowIso = new Date().toISOString();
|
|
const record: RuntimeServiceRecord = {
|
|
id: runtimeId,
|
|
companyId: input.agent.companyId,
|
|
projectId: input.workspace.projectId,
|
|
projectWorkspaceId: input.workspace.workspaceId,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
issueId: input.issue?.id ?? null,
|
|
serviceName,
|
|
status: "starting",
|
|
lifecycle,
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
reuseKey: input.reuseKey,
|
|
command,
|
|
cwd: serviceCwd,
|
|
port,
|
|
url,
|
|
provider: "local_process",
|
|
providerRef: null,
|
|
ownerAgentId: input.agent.id ?? null,
|
|
startedByRunId,
|
|
lastUsedAt: nowIso,
|
|
startedAt: nowIso,
|
|
stoppedAt: null,
|
|
stopPolicy,
|
|
healthStatus: "unknown",
|
|
exposure: reservedExposure?.status ?? null,
|
|
reused: false,
|
|
db: input.db,
|
|
child: null,
|
|
leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(),
|
|
idleTimer: null,
|
|
envFingerprint,
|
|
serviceKey,
|
|
profileKind: "workspace-runtime",
|
|
processGroupId: null,
|
|
exposureHandle: reservedExposure?.handle ?? null,
|
|
backendUrl,
|
|
exposureConfig,
|
|
};
|
|
if (reservedExposure) {
|
|
// The broker reservation and its unguessable handle must be durable before
|
|
// the child can bind, so a server crash cannot lose cleanup authority.
|
|
try {
|
|
await persistRuntimeServiceRecord(input.db, record);
|
|
} catch (error) {
|
|
await cleanupRecordExposure(record);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await ensureServerWorkspaceLinksCurrent(serviceCwd, {
|
|
onLog: input.onLog,
|
|
});
|
|
} catch (error) {
|
|
releasePortReservation(reservedPort);
|
|
releasePortReservation(claimedIdentityPort);
|
|
if (reservedExposure) await cleanupRecordExposure(record);
|
|
throw error;
|
|
}
|
|
|
|
const shell = resolveShell();
|
|
const serviceLog = await openLocalServiceLogFile(serviceKey);
|
|
let child: ChildProcess;
|
|
try {
|
|
child = spawn(shell, ["-lc", command], {
|
|
cwd: serviceCwd,
|
|
env,
|
|
detached: process.platform !== "win32",
|
|
// The service receives duplicate append-only file descriptors. Closing
|
|
// Paperclip (or this parent handle below) cannot strand a request logger
|
|
// on an orphaned socketpair during startup reconciliation.
|
|
stdio: ["ignore", serviceLog.handle.fd, serviceLog.handle.fd],
|
|
});
|
|
} finally {
|
|
await serviceLog.handle.close();
|
|
}
|
|
record.child = child;
|
|
record.providerRef = child.pid ? String(child.pid) : null;
|
|
record.processGroupId = child.pid ?? null;
|
|
const spawnErrorPromise = new Promise<never>((_, reject) => {
|
|
child.once("error", (err) => {
|
|
reject(err);
|
|
});
|
|
});
|
|
const earlyExitPromise = new Promise<never>((_, reject) => {
|
|
// `close` follows `exit` after the child's inherited stdout/stderr file
|
|
// descriptors are closed. Waiting for it makes the startup log excerpt
|
|
// deterministic instead of racing the final validation line.
|
|
child.once("close", (code, signal) => {
|
|
reject(new Error(
|
|
`service process exited before readiness (code ${code ?? "unknown"}, signal ${signal ?? "none"})`,
|
|
));
|
|
});
|
|
});
|
|
const readServiceOutputExcerpt = async () => {
|
|
try {
|
|
const contents = await fs.readFile(serviceLog.logPath);
|
|
return contents.subarray(Math.max(serviceLog.startOffset, contents.length - 4096)).toString("utf8");
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
if (child.pid) {
|
|
await writeLocalServiceRegistryRecord({
|
|
version: 1,
|
|
serviceKey,
|
|
profileKind: "workspace-runtime",
|
|
serviceName,
|
|
command,
|
|
cwd: serviceCwd,
|
|
envFingerprint: serviceIdentityFingerprint,
|
|
port,
|
|
url: backendUrl,
|
|
pid: child.pid,
|
|
processGroupId: child.pid,
|
|
provider: "local_process",
|
|
runtimeServiceId: record.id,
|
|
reuseKey: input.reuseKey,
|
|
startedAt: record.startedAt,
|
|
lastSeenAt: record.lastUsedAt,
|
|
metadata: {
|
|
projectId: record.projectId,
|
|
projectWorkspaceId: record.projectWorkspaceId,
|
|
executionWorkspaceId: record.executionWorkspaceId,
|
|
issueId: record.issueId,
|
|
scopeType: record.scopeType,
|
|
scopeId: record.scopeId,
|
|
},
|
|
});
|
|
}
|
|
|
|
const ownershipCheckedPorts = port
|
|
? exposureConfig
|
|
? [
|
|
port,
|
|
...(exposureConfig.includePaperclipViteHmr ? [deriveViteHmrPort(port)] : []),
|
|
]
|
|
: canAllocateFixedPort
|
|
? [port]
|
|
: []
|
|
: [];
|
|
const readinessPromise = Promise.race([
|
|
Promise.all([
|
|
waitForRuntimeServiceReadiness({
|
|
service: input.service,
|
|
serviceName,
|
|
command,
|
|
// An exposed runtime is loopback-only by construction, so its readiness
|
|
// probe has to target loopback too. The fallback target is the display
|
|
// URL (a MagicDNS name), which only ever answered because the guest was
|
|
// wrongly bound to the wildcard (PAP-17256).
|
|
url: exposureConfig ? rewriteUrlHostToLoopback(backendUrl) : backendUrl,
|
|
readinessUrl: exposureConfig ? rewriteUrlHostToLoopback(readinessUrl) : readinessUrl,
|
|
}),
|
|
...ownershipCheckedPorts.map((ownedPort) =>
|
|
waitForAllocatedPortBind({ service: input.service, port: ownedPort, child })
|
|
),
|
|
]).then(() => undefined),
|
|
spawnErrorPromise,
|
|
earlyExitPromise,
|
|
]).then(async () => {
|
|
releasePortReservation(reservedPort);
|
|
releasePortReservation(claimedIdentityPort);
|
|
if (record.exposureConfig && record.exposureHandle && record.port) {
|
|
const provisioned = await provisionExposure(workspaceRuntimeExposureDeps, {
|
|
runtimeId: record.id,
|
|
config: record.exposureConfig,
|
|
handle: record.exposureHandle,
|
|
hostname: exposureHostname!,
|
|
appPort: record.port,
|
|
});
|
|
record.exposure = provisioned.status;
|
|
record.exposureHandle = provisioned.handle;
|
|
record.url = provisioned.status.publicUrl;
|
|
await persistRuntimeServiceRecord(record.db, record);
|
|
if (provisioned.status.state !== "ready" || !record.url) {
|
|
// Carry the reason, not just the code: a bare `listener_ownership_mismatch`
|
|
// in the operation log is what made PAP-17254 undiagnosable (PAP-17256).
|
|
const code = provisioned.status.lastError ?? "unknown error";
|
|
throw new Error(
|
|
`HTTPS exposure failed: ${code}${provisioned.errorDetail ? ` — ${provisioned.errorDetail}` : ""}`,
|
|
);
|
|
}
|
|
}
|
|
// Transport readiness only proves a listener answered. A managed workspace
|
|
// must additionally satisfy the protected readiness contract — own database,
|
|
// cloned rows, login handoff, and matching instance/workspace identity —
|
|
// before it may be published as running/healthy (PAP-17572).
|
|
if (managedWorkspaceIdentity) {
|
|
const publishHealthUrl = resolveRuntimeServiceHealthUrl(
|
|
record.port ? `http://127.0.0.1:${record.port}` : rewriteUrlHostToLoopback(record.url ?? backendUrl),
|
|
{ serviceName, command },
|
|
);
|
|
if (!publishHealthUrl) {
|
|
throw new Error("Managed workspace readiness gate could not resolve a health URL");
|
|
}
|
|
let gate = await waitForManagedWorkspaceReadiness({
|
|
healthUrl: publishHealthUrl,
|
|
identity: managedWorkspaceIdentity,
|
|
});
|
|
if (gate.ok) {
|
|
if (!record.db) {
|
|
throw new Error("Managed workspace readiness gate could not resolve the control-plane database");
|
|
}
|
|
gate = await probeManagedWorkspaceHandoffSubjects({
|
|
db: record.db,
|
|
healthUrl: publishHealthUrl,
|
|
identity: managedWorkspaceIdentity,
|
|
});
|
|
}
|
|
if (!gate.ok) {
|
|
logManagedWorkspaceReadinessRejection({
|
|
executionWorkspaceId: managedWorkspaceIdentity.executionWorkspaceId,
|
|
healthUrl: publishHealthUrl,
|
|
result: gate,
|
|
});
|
|
if (shouldBlockPublicationOnReadiness(gate)) {
|
|
throw new Error(
|
|
`Workspace is not ready to publish (${gate.reason}${gate.detail ? `: ${gate.detail}` : ""})`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
record.status = "running";
|
|
record.healthStatus = "healthy";
|
|
record.lastUsedAt = new Date().toISOString();
|
|
record.stoppedAt = null;
|
|
const serviceOutputExcerpt = await readServiceOutputExcerpt();
|
|
if (serviceOutputExcerpt && input.onLog) {
|
|
await input.onLog("stdout", `[service:${serviceName}] ${serviceOutputExcerpt}`);
|
|
}
|
|
await touchLocalServiceRegistryRecord(record.serviceKey, {
|
|
runtimeServiceId: record.id,
|
|
lastSeenAt: record.lastUsedAt,
|
|
});
|
|
}).catch(async (err) => {
|
|
releasePortReservation(reservedPort);
|
|
releasePortReservation(claimedIdentityPort);
|
|
const failureMessage = err instanceof Error ? err.message : String(err);
|
|
const serviceOutputExcerpt = await readServiceOutputExcerpt();
|
|
const bindCollision = !exposureConfig && (
|
|
err instanceof RuntimeServicePortBindCollision || Boolean(
|
|
port
|
|
&& (input.allowFixedPortFallback || portType === "auto")
|
|
&& /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${serviceOutputExcerpt}`),
|
|
)
|
|
);
|
|
// An exposed guest that exits with EADDRINUSE on its ASSIGNED port lost the
|
|
// port after allocation. Only the assigned app or HMR port is re-allocatable
|
|
// from the dedicated broker range, so quarantine and re-allocation apply only
|
|
// when the failure names one of those ports. An unrelated auxiliary-port
|
|
// conflict (a different port the guest also bound) leaves the valid pair
|
|
// intact and surfaces as a terminal error, not a quarantine that burns the
|
|
// bounded retries.
|
|
const exposureAssignedPorts: number[] =
|
|
exposureConfig && port
|
|
? [port, ...(exposureConfig.includePaperclipViteHmr ? [deriveViteHmrPort(port)] : [])]
|
|
: [];
|
|
const collisionText = `${failureMessage}\n${serviceOutputExcerpt}`;
|
|
const exposureNamedPorts = exposureAssignedPorts.filter((candidate) =>
|
|
eaddrinuseTextNamesPort(collisionText, candidate),
|
|
);
|
|
// The guest owns its own output, so an assigned-port EADDRINUSE line is a
|
|
// claim, not proof. A managed guest can print a synthetic EADDRINUSE line for
|
|
// its assigned port with no host listener behind it. A quarantine on that text
|
|
// alone would burn the shared exposure-port pool across repeated starts. So the
|
|
// runtime reads live host listener state and quarantines only after it confirms
|
|
// that a real host listener owns the port.
|
|
const exposureTextNamesAssignedPort = Boolean(
|
|
exposureConfig && port && exposureNamedPorts.length > 0,
|
|
);
|
|
let exposureCollisionDiagnosis: string | null = null;
|
|
let exposureHostCollision = false;
|
|
if (exposureTextNamesAssignedPort) {
|
|
const facts: string[] = [];
|
|
const hostStates: ExposurePortHostState[] = [];
|
|
for (const collisionPort of exposureAssignedPorts) {
|
|
// `readLocalServicePortOwner` reads lsof and returns the listener pid, so a
|
|
// non-null pid also proves a present listener. `readListenerBindFacts` reads
|
|
// /proc for the same presence signal and the bound addresses.
|
|
const ownerPid = await readLocalServicePortOwner(collisionPort).catch(() => null);
|
|
const bind = await readListenerBindFacts(collisionPort).catch(() => null);
|
|
// Read the owner process group id too. The guest runs as a shell process
|
|
// group leader, so the real dev server usually binds the port from a
|
|
// descendant. The classify step matches the shell pid against the owner pgid
|
|
// to keep a guest descendant from looking like an external owner.
|
|
const ownerProcessGroupId =
|
|
ownerPid != null ? await readLocalServiceProcessGroupId(ownerPid).catch(() => null) : null;
|
|
hostStates.push({
|
|
port: collisionPort,
|
|
named: exposureNamedPorts.includes(collisionPort),
|
|
listenerPresent: Boolean(bind?.present) || ownerPid != null,
|
|
ownerPid,
|
|
ownerProcessGroupId,
|
|
});
|
|
const boundTo = bind?.present ? bind.addresses.join(", ") : "no listener";
|
|
facts.push(`port ${collisionPort} bound to ${boundTo}, owner pid ${ownerPid ?? "none"}`);
|
|
}
|
|
exposureHostCollision = classifyExposureHostCollisions({
|
|
childPid: child.pid ?? null,
|
|
ports: hostStates,
|
|
}).hostCollision;
|
|
exposureCollisionDiagnosis = facts.join("; ");
|
|
}
|
|
// Quarantine the whole assigned pair, not only the named port. The broker
|
|
// allocates the app and HMR ports as one unit, so a re-allocation must skip
|
|
// both to land on the next free pair.
|
|
const exposureCollisionPorts: number[] = exposureHostCollision ? exposureAssignedPorts : [];
|
|
if (child.pid) {
|
|
await terminateLocalService({
|
|
pid: child.pid,
|
|
processGroupId: child.pid,
|
|
port,
|
|
});
|
|
}
|
|
await cleanupRecordExposure(record, { preserveFailure: true });
|
|
record.status = "stopped";
|
|
record.healthStatus = "unhealthy";
|
|
record.lastUsedAt = new Date().toISOString();
|
|
record.stoppedAt = new Date().toISOString();
|
|
await removeLocalServiceRegistryRecord(record.serviceKey).catch(() => undefined);
|
|
if (exposureConfig) {
|
|
await persistRuntimeServiceRecord(record.db, record).catch(() => undefined);
|
|
}
|
|
if (bindCollision && port) throw new RuntimeServicePortBindCollision(port);
|
|
if (exposureHostCollision && port) {
|
|
// A verified host listener holds the assigned exposure port. Quarantine the
|
|
// pair so the bounded re-allocation never re-offers it, then throw a retryable
|
|
// collision. `startLocalRuntimeService` re-runs allocation, which skips the
|
|
// quarantined pair and takes the next free pair inside the dedicated range.
|
|
// This hardens a real host that races an external process for a range port.
|
|
for (const collisionPort of exposureCollisionPorts) {
|
|
quarantinedRuntimeExposurePorts.add(collisionPort);
|
|
}
|
|
if (input.onLog) {
|
|
await input.onLog(
|
|
"stderr",
|
|
`[service:${serviceName}] exposure port ${port} collided during startup (EADDRINUSE); `
|
|
+ `${exposureCollisionDiagnosis ?? "owner unavailable"}. `
|
|
+ `Quarantined pair ${exposureCollisionPorts.join("/")} and reallocating.\n`,
|
|
).catch(() => undefined);
|
|
}
|
|
throw new RuntimeServicePortBindCollision(port, exposureCollisionDiagnosis, true);
|
|
}
|
|
const deploymentBindConflict = /local_trusted requires server\.bind=loopback/i.test(
|
|
`${failureMessage}\n${serviceOutputExcerpt}`,
|
|
);
|
|
// The guest reported an assigned-port EADDRINUSE, but no host listener owned
|
|
// the port. Explain that the runtime did not quarantine the pair, so a future
|
|
// occurrence needs no diagnostic cycle and the pool stays intact.
|
|
const unverifiedExposureCollision = exposureTextNamesAssignedPort && !exposureHostCollision;
|
|
const actionableFailure = deploymentBindConflict
|
|
? `${failureMessage} | deployment/bind conflict: local_trusted requires server.bind=loopback; the managed runtime requested an incompatible bind mode`
|
|
: unverifiedExposureCollision
|
|
? `${failureMessage} | exposure port collision not verified: the guest reported EADDRINUSE on assigned port ${exposureNamedPorts.join("/")}, but no host listener owns it (${exposureCollisionDiagnosis ?? "owner unavailable"}); the runtime did not quarantine the pair`
|
|
: failureMessage;
|
|
throw new Error(
|
|
`Failed to start runtime service "${serviceName}": ${actionableFailure}${serviceOutputExcerpt ? ` | output: ${serviceOutputExcerpt.trim()}` : ""}`,
|
|
);
|
|
});
|
|
|
|
return { record, readiness: readinessPromise };
|
|
}
|
|
|
|
async function prepareRuntimeProvisioning(
|
|
input: StartLocalRuntimeServiceInput,
|
|
): Promise<RuntimeServiceRecord | null> {
|
|
const runtimeProvisionCommand = asString(input.runtimeProvisionCommand, "").trim();
|
|
if (!runtimeProvisionCommand) return null;
|
|
const coordinator = input.provisionCoordinator ?? createRuntimeProvisionCoordinator();
|
|
if (coordinator.promise) {
|
|
await coordinator.promise;
|
|
return null;
|
|
}
|
|
|
|
const identity = resolveRuntimeServiceReuseIdentity({
|
|
service: input.service,
|
|
workspace: input.workspace,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
scopeType: input.scopeType,
|
|
scopeId: input.scopeId,
|
|
});
|
|
if (!identity.command) throw new Error(`Runtime service "${identity.serviceName}" is missing command`);
|
|
const provisioningRecord = createProvisioningRuntimeServiceRecord(input, identity);
|
|
await persistRuntimeServiceRecord(input.db, provisioningRecord);
|
|
if (input.onLog) {
|
|
await input.onLog(
|
|
"stdout",
|
|
`[service:${identity.serviceName}] provisioning runtime dependencies...\n`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
coordinator.promise = runRuntimeProvisionWithWorkspaceMutex(input);
|
|
await coordinator.promise;
|
|
provisioningRecord.status = "starting";
|
|
provisioningRecord.lastUsedAt = new Date().toISOString();
|
|
await persistRuntimeServiceRecord(input.db, provisioningRecord);
|
|
return provisioningRecord;
|
|
} catch (error) {
|
|
const nowIso = new Date().toISOString();
|
|
provisioningRecord.status = "failed";
|
|
provisioningRecord.healthStatus = "unhealthy";
|
|
provisioningRecord.lastUsedAt = nowIso;
|
|
provisioningRecord.stoppedAt = nowIso;
|
|
await persistRuntimeServiceRecord(input.db, provisioningRecord).catch(() => undefined);
|
|
if (input.onLog) {
|
|
await input.onLog(
|
|
"stderr",
|
|
`[service:${provisioningRecord.serviceName}] runtime provisioning failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function startLocalRuntimeService(
|
|
input: StartLocalRuntimeServiceInput,
|
|
options?: { deferReadiness?: boolean },
|
|
): Promise<LocalRuntimeServiceStart> {
|
|
const runtimeProvisionCommand = asString(input.runtimeProvisionCommand, "").trim();
|
|
const provisioningRecord = input.preparedProvisioningRecord === undefined
|
|
? await prepareRuntimeProvisioning(input)
|
|
: input.preparedProvisioningRecord;
|
|
let started: LocalRuntimeServiceStart | null = null;
|
|
const excludedPorts = new Set(input.excludedPorts ?? []);
|
|
const portConfig = parseObject(input.service.port);
|
|
const portType = asString(portConfig.type, "");
|
|
const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0));
|
|
const fixedPortFallbackEnabled = Boolean(
|
|
input.allowFixedPortFallback && portType !== "auto" && explicitPort > 0,
|
|
);
|
|
const retryBindCollisions = fixedPortFallbackEnabled || portType === "auto";
|
|
const deferReadiness = Boolean(options?.deferReadiness);
|
|
|
|
try {
|
|
for (let attempt = 0; attempt < WORKSPACE_RUNTIME_PORT_ALLOCATION_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
started = await spawnLocalRuntimeService({
|
|
...input,
|
|
excludedPorts,
|
|
runtimeServiceId: provisioningRecord?.id ?? input.runtimeServiceId,
|
|
});
|
|
if (runtimeProvisionCommand) {
|
|
await persistRuntimeServiceRecord(input.db, started.record);
|
|
}
|
|
if (provisioningRecord && started.record.id !== provisioningRecord.id && input.db) {
|
|
await input.db
|
|
.delete(workspaceRuntimeServices)
|
|
.where(eq(workspaceRuntimeServices.id, provisioningRecord.id));
|
|
}
|
|
if (!deferReadiness) {
|
|
await started.readiness;
|
|
}
|
|
return started;
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof RuntimeServicePortBindCollision)
|
|
|| !(retryBindCollisions || error.exposureReallocatable)
|
|
) {
|
|
throw error;
|
|
}
|
|
excludedPorts.add(error.port);
|
|
started = null;
|
|
}
|
|
}
|
|
|
|
if (fixedPortFallbackEnabled && input.executionWorkspaceId) {
|
|
throw await buildRuntimePortAllocationConflict({
|
|
db: input.db,
|
|
companyId: input.agent.companyId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
preferredPort: explicitPort,
|
|
attemptedPorts: [...excludedPorts],
|
|
});
|
|
}
|
|
throw conflict("No safe automatically allocated runtime service port is available.", {
|
|
code: "workspace_runtime_port_allocation_exhausted",
|
|
attemptedPortCount: excludedPorts.size,
|
|
remediation: "Retry the start or configure a different runtime service port.",
|
|
});
|
|
} catch (error) {
|
|
if (!started && provisioningRecord && provisioningRecord.status === "starting") {
|
|
const nowIso = new Date().toISOString();
|
|
provisioningRecord.status = "failed";
|
|
provisioningRecord.healthStatus = "unhealthy";
|
|
provisioningRecord.lastUsedAt = nowIso;
|
|
provisioningRecord.stoppedAt = nowIso;
|
|
await persistRuntimeServiceRecord(input.db, provisioningRecord).catch(() => undefined);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function scheduleIdleStop(record: RuntimeServiceRecord) {
|
|
clearIdleTimer(record);
|
|
const stopType = asString(record.stopPolicy?.type, "manual");
|
|
if (stopType !== "idle_timeout") return;
|
|
const idleSeconds = Math.max(1, asNumber(record.stopPolicy?.idleSeconds, 1800));
|
|
record.idleTimer = setTimeout(() => {
|
|
stopRuntimeService(record.id).catch(() => undefined);
|
|
}, idleSeconds * 1000);
|
|
}
|
|
|
|
async function cleanupRecordExposure(
|
|
record: RuntimeServiceRecord,
|
|
options?: { preserveFailure?: boolean },
|
|
) {
|
|
if (!record.exposure) return;
|
|
const previous = record.exposure;
|
|
const ports = previous.listeners.map((listener) => listener.targetPort);
|
|
const result = await deprovisionExposure(workspaceRuntimeExposureDeps, {
|
|
runtimeId: record.id,
|
|
handle: record.exposureHandle,
|
|
ports,
|
|
});
|
|
for (const port of result.quarantinedPorts) quarantinedRuntimeExposurePorts.add(port);
|
|
// Drop the in-process pair claim on teardown. The *lease* reservation is what
|
|
// still protects the pair from another workspace (PAP-17419) — this only
|
|
// releases the short-lived hold that keeps concurrent allocators apart, and
|
|
// keeping it would block this very lane's own restart.
|
|
if (record.port !== null && isRuntimeExposureAppPort(record.port)) {
|
|
exposurePortPairClaims.release({ appPort: record.port, hmrPort: deriveViteHmrPort(record.port) });
|
|
}
|
|
if (result.status.state === "removed") {
|
|
record.exposureHandle = null;
|
|
record.exposure = options?.preserveFailure && previous.state === "failed"
|
|
? { ...previous, publicUrl: null, updatedAt: new Date().toISOString() }
|
|
: { ...previous, state: "removed", publicUrl: null, lastError: null, updatedAt: new Date().toISOString() };
|
|
} else {
|
|
record.exposure = {
|
|
...previous,
|
|
state: "cleanup_pending",
|
|
publicUrl: null,
|
|
lastError: result.status.lastError,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
record.url = null;
|
|
await persistRuntimeServiceRecord(record.db, record).catch(() => undefined);
|
|
}
|
|
|
|
async function stopRuntimeService(serviceId: string) {
|
|
const record = runtimeServicesById.get(serviceId);
|
|
if (!record) return;
|
|
clearIdleTimer(record);
|
|
// Remove any public exposure first, but keep the process registered and the
|
|
// row non-stopped until verified termination succeeds.
|
|
await cleanupRecordExposure(record);
|
|
if (record.child && record.child.pid) {
|
|
await terminateLocalService({
|
|
pid: record.child.pid,
|
|
processGroupId: record.processGroupId ?? record.child.pid,
|
|
port: record.port,
|
|
});
|
|
} else if (record.providerRef) {
|
|
const pid = Number.parseInt(record.providerRef, 10);
|
|
if (Number.isInteger(pid) && pid > 0) {
|
|
await terminateLocalService({
|
|
pid,
|
|
processGroupId: record.processGroupId,
|
|
port: record.port,
|
|
});
|
|
}
|
|
}
|
|
record.status = "stopped";
|
|
record.healthStatus = "unknown";
|
|
record.lastUsedAt = new Date().toISOString();
|
|
record.stoppedAt = new Date().toISOString();
|
|
runtimeServicesById.delete(serviceId);
|
|
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
|
|
runtimeServicesByReuseKey.delete(record.reuseKey);
|
|
}
|
|
await removeLocalServiceRegistryRecord(record.serviceKey);
|
|
await persistRuntimeServiceRecord(record.db, record);
|
|
}
|
|
|
|
async function findHealthyRunningRuntimeService(reuseKey: string | null) {
|
|
const existingId = reuseKey ? runtimeServicesByReuseKey.get(reuseKey) : null;
|
|
const existing = existingId ? runtimeServicesById.get(existingId) : null;
|
|
if (!existing || existing.status !== "running") return null;
|
|
const healthInput = {
|
|
db: existing.db,
|
|
serviceName: existing.serviceName,
|
|
command: existing.command,
|
|
provider: existing.provider,
|
|
port: existing.port,
|
|
cwd: existing.cwd,
|
|
executionWorkspaceId: existing.executionWorkspaceId,
|
|
companyId: existing.companyId,
|
|
};
|
|
let healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput);
|
|
if (!healthy) {
|
|
// A single timeout or connection reset is not enough evidence to destroy a
|
|
// shared runtime that active runs may still use. Confirm the failure after
|
|
// a short bounded delay before entering the destructive replacement path.
|
|
await delay(250);
|
|
healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput);
|
|
}
|
|
if (healthy) return existing;
|
|
if (existing.leaseRunIds.size > 0) {
|
|
existing.healthStatus = "unhealthy";
|
|
if (reuseKey && runtimeServicesByReuseKey.get(reuseKey) === existing.id) {
|
|
runtimeServicesByReuseKey.delete(reuseKey);
|
|
}
|
|
await persistRuntimeServiceRecord(existing.db, existing);
|
|
return null;
|
|
}
|
|
await stopRuntimeService(existing.id);
|
|
return null;
|
|
}
|
|
|
|
async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input: {
|
|
db: Db;
|
|
executionWorkspaceId: string;
|
|
}) {
|
|
const now = new Date();
|
|
const exposureRows = await input.db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
exposure: workspaceRuntimeServices.exposure,
|
|
exposureHandle: workspaceRuntimeServices.exposureHandle,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(
|
|
and(
|
|
eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId),
|
|
inArray(workspaceRuntimeServices.status, ["provisioning", "starting", "running"]),
|
|
),
|
|
);
|
|
for (const row of exposureRows) {
|
|
if (!row.exposure) continue;
|
|
const cleanup = await deprovisionExposure(workspaceRuntimeExposureDeps, {
|
|
runtimeId: row.id,
|
|
handle: row.exposureHandle,
|
|
ports: row.exposure.listeners.map((listener) => listener.targetPort),
|
|
});
|
|
for (const port of cleanup.quarantinedPorts) quarantinedRuntimeExposurePorts.add(port);
|
|
const exposure: RuntimeExposureStatus = {
|
|
...row.exposure,
|
|
state: cleanup.status.state,
|
|
publicUrl: null,
|
|
lastError: cleanup.status.lastError,
|
|
updatedAt: now.toISOString(),
|
|
};
|
|
await input.db
|
|
.update(workspaceRuntimeServices)
|
|
.set({
|
|
exposure,
|
|
exposureHandle: cleanup.status.state === "removed" ? null : row.exposureHandle,
|
|
url: null,
|
|
})
|
|
.where(eq(workspaceRuntimeServices.id, row.id));
|
|
}
|
|
await input.db
|
|
.update(workspaceRuntimeServices)
|
|
.set({
|
|
status: "stopped",
|
|
healthStatus: "unknown",
|
|
stoppedAt: now,
|
|
lastUsedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId),
|
|
inArray(workspaceRuntimeServices.status, ["provisioning", "starting", "running"]),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Corroboration for a reclamation driven by persisted rows rather than by a live
|
|
* operation (PAP-17285).
|
|
*
|
|
* `ownedListeners` is the broker's own current ownership view (`broker.list()`),
|
|
* or `null` when the broker could not be reached. Passing it switches
|
|
* `cleanupPersistedExposureRows` from "trust the row" to "trust the broker",
|
|
* which is the difference between reclaiming what is actually published and
|
|
* issuing removals from stale bookkeeping. Targeted stop paths omit it and keep
|
|
* their existing behaviour, because there the caller named the runtime.
|
|
*/
|
|
interface ExposureReclaimCorroboration {
|
|
ownedListeners: Array<{ runtimeId: string; port: number }> | null;
|
|
}
|
|
|
|
export type StaleExposureReclaimDecision =
|
|
/** Broker unreachable: prove nothing, mutate nothing, surface cleanup_pending. */
|
|
| { action: "defer"; reason: "broker_unreachable" }
|
|
/** Broker owns nothing for this runtime: clear local bookkeeping, no Serve op. */
|
|
| { action: "clear_bookkeeping"; reason: "not_owned_by_broker" }
|
|
/** Broker still publishes this runtime's mapping: reclaiming it is correct. */
|
|
| { action: "reclaim"; reason: "owned_by_broker" };
|
|
|
|
/**
|
|
* Decide what a persisted-row-driven reclamation may do, given the broker's live
|
|
* ownership view (PAP-17285).
|
|
*
|
|
* Extracted as a pure function because it is the entire safety contract for the
|
|
* global startup sweep, and the sweep itself needs a database. Every branch is
|
|
* asserted by reason code in `workspace-runtime-exposure-backfill.test.ts`.
|
|
*/
|
|
export function decideStaleExposureReclaim(input: {
|
|
runtimeId: string;
|
|
ownedListeners: Array<{ runtimeId: string; port: number }> | null;
|
|
}): StaleExposureReclaimDecision {
|
|
if (input.ownedListeners === null) {
|
|
return { action: "defer", reason: "broker_unreachable" };
|
|
}
|
|
const owned = input.ownedListeners.some((listener) => listener.runtimeId === input.runtimeId);
|
|
return owned
|
|
? { action: "reclaim", reason: "owned_by_broker" }
|
|
: { action: "clear_bookkeeping", reason: "not_owned_by_broker" };
|
|
}
|
|
|
|
async function cleanupPersistedExposureRows(
|
|
db: Db,
|
|
rows: Array<{
|
|
id: string;
|
|
exposure: RuntimeExposureStatus | null;
|
|
exposureHandle: string | null;
|
|
}>,
|
|
corroboration?: ExposureReclaimCorroboration,
|
|
) {
|
|
for (const row of rows) {
|
|
if (!row.exposure) continue;
|
|
|
|
if (corroboration) {
|
|
// Fail closed toward PRESERVATION. A stale row is not evidence that a
|
|
// mapping is ours to delete: rows outlive the lanes that created them, get
|
|
// duplicated as ports are recycled, and can be days old. Reclaiming on that
|
|
// basis alone is what destroyed the `42000/52000` mappings.
|
|
const decision = decideStaleExposureReclaim({
|
|
runtimeId: row.id,
|
|
ownedListeners: corroboration.ownedListeners,
|
|
});
|
|
if (decision.action === "defer") {
|
|
// Broker unreachable — we cannot prove anything. Never guess; surface it.
|
|
await db
|
|
.update(workspaceRuntimeServices)
|
|
.set({
|
|
exposure: {
|
|
...row.exposure,
|
|
state: "cleanup_pending",
|
|
publicUrl: null,
|
|
lastError: decision.reason,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
})
|
|
.where(eq(workspaceRuntimeServices.id, row.id));
|
|
continue;
|
|
}
|
|
if (decision.action === "clear_bookkeeping") {
|
|
// The broker attributes nothing to this runtime, so there is nothing of
|
|
// ours published. This is stale local bookkeeping: clear it WITHOUT
|
|
// issuing any Serve mutation. `exposure.listeners` is retained so the
|
|
// historical mapping stays inspectable and restorable.
|
|
await db
|
|
.update(workspaceRuntimeServices)
|
|
.set({
|
|
url: null,
|
|
exposure: {
|
|
...row.exposure,
|
|
state: "removed",
|
|
publicUrl: null,
|
|
lastError: null,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
exposureHandle: null,
|
|
})
|
|
.where(eq(workspaceRuntimeServices.id, row.id));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const cleanup = await deprovisionExposure(workspaceRuntimeExposureDeps, {
|
|
runtimeId: row.id,
|
|
handle: row.exposureHandle,
|
|
ports: row.exposure.listeners.map((listener) => listener.targetPort),
|
|
});
|
|
for (const port of cleanup.quarantinedPorts) quarantinedRuntimeExposurePorts.add(port);
|
|
await db
|
|
.update(workspaceRuntimeServices)
|
|
.set({
|
|
url: null,
|
|
exposure: {
|
|
...row.exposure,
|
|
state: cleanup.status.state,
|
|
publicUrl: null,
|
|
lastError: cleanup.status.lastError,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
exposureHandle: cleanup.status.state === "removed" ? null : row.exposureHandle,
|
|
})
|
|
.where(eq(workspaceRuntimeServices.id, row.id));
|
|
}
|
|
}
|
|
|
|
function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord) {
|
|
record.db = db;
|
|
runtimeServicesById.set(record.id, record);
|
|
if (record.reuseKey) {
|
|
runtimeServicesByReuseKey.set(record.reuseKey, record.id);
|
|
}
|
|
|
|
record.child?.on("exit", (code, signal) => {
|
|
const current = runtimeServicesById.get(record.id);
|
|
if (!current) return;
|
|
clearIdleTimer(current);
|
|
current.status = code === 0 || signal === "SIGTERM" ? "stopped" : "failed";
|
|
current.healthStatus = current.status === "failed" ? "unhealthy" : "unknown";
|
|
current.lastUsedAt = new Date().toISOString();
|
|
current.stoppedAt = new Date().toISOString();
|
|
runtimeServicesById.delete(current.id);
|
|
if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) {
|
|
runtimeServicesByReuseKey.delete(current.reuseKey);
|
|
}
|
|
void (async () => {
|
|
// The child exited on its own. Record the terminal status as best effort.
|
|
// The persist can fail when a parent row is already gone: a caller can
|
|
// delete the project or the company while this service still runs, and the
|
|
// `project_id` foreign key then rejects the write. Catch every error here,
|
|
// or the detached persist becomes an unhandled rejection and crashes the
|
|
// host. This path runs off the child `exit` event, so no caller awaits it.
|
|
try {
|
|
await cleanupRecordExposure(current);
|
|
await removeLocalServiceRegistryRecord(current.serviceKey);
|
|
await persistRuntimeServiceRecord(db, current);
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
console.warn(
|
|
`[workspace-runtime] runtime service exit cleanup failed for ${current.id}: ${detail}`,
|
|
);
|
|
}
|
|
})();
|
|
});
|
|
}
|
|
|
|
function readRuntimeServiceEntries(config: Record<string, unknown>) {
|
|
return listWorkspaceServiceCommandDefinitions(parseObject(config.workspaceRuntime))
|
|
.map((command) => command.rawConfig);
|
|
}
|
|
|
|
export function listConfiguredRuntimeServiceEntries(config: Record<string, unknown>) {
|
|
return readRuntimeServiceEntries(config);
|
|
}
|
|
|
|
function readConfiguredServiceStates(config: Record<string, unknown>) {
|
|
const raw = parseObject(config.serviceStates);
|
|
const states: WorkspaceRuntimeServiceStateMap = {};
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
if (value === "running" || value === "stopped" || value === "manual") {
|
|
states[key] = value;
|
|
}
|
|
}
|
|
return states;
|
|
}
|
|
|
|
function readDesiredRuntimeState(value: unknown): WorkspaceRuntimeDesiredState | null {
|
|
return value === "running" || value === "stopped" || value === "manual" ? value : null;
|
|
}
|
|
|
|
export function buildWorkspaceRuntimeDesiredStatePatch(input: {
|
|
config: Record<string, unknown>;
|
|
currentDesiredState: WorkspaceRuntimeDesiredState | null;
|
|
currentServiceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
|
|
action: "start" | "stop" | "restart";
|
|
serviceIndex?: number | null;
|
|
}): {
|
|
desiredState: WorkspaceRuntimeDesiredState;
|
|
serviceStates: WorkspaceRuntimeServiceStateMap | null;
|
|
} {
|
|
const configuredServices = listConfiguredRuntimeServiceEntries(input.config);
|
|
const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.currentDesiredState) ?? "stopped";
|
|
const nextServiceStates: WorkspaceRuntimeServiceStateMap = {};
|
|
|
|
for (let index = 0; index < configuredServices.length; index += 1) {
|
|
nextServiceStates[String(index)] = input.currentServiceStates?.[String(index)] ?? fallbackState;
|
|
}
|
|
|
|
const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running";
|
|
const applyActionState = (index: number) => {
|
|
const key = String(index);
|
|
// Manual services are intentionally left under operator control even when
|
|
// an API action targets that individual service.
|
|
if (nextServiceStates[key] === "manual") return;
|
|
nextServiceStates[key] = nextState;
|
|
};
|
|
if (input.serviceIndex === undefined || input.serviceIndex === null) {
|
|
for (let index = 0; index < configuredServices.length; index += 1) {
|
|
applyActionState(index);
|
|
}
|
|
} else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) {
|
|
applyActionState(input.serviceIndex);
|
|
}
|
|
|
|
const desiredState = Object.values(nextServiceStates).some((state) => state === "running")
|
|
? "running"
|
|
: Object.values(nextServiceStates).some((state) => state === "manual")
|
|
? "manual"
|
|
: "stopped";
|
|
|
|
return {
|
|
desiredState,
|
|
serviceStates: Object.keys(nextServiceStates).length > 0 ? nextServiceStates : null,
|
|
};
|
|
}
|
|
|
|
function selectRuntimeServiceEntries(input: {
|
|
config: Record<string, unknown>;
|
|
serviceIndex?: number | null;
|
|
respectDesiredStates?: boolean;
|
|
defaultDesiredState?: WorkspaceRuntimeDesiredState | null;
|
|
serviceStates?: WorkspaceRuntimeServiceStateMap | null;
|
|
}) {
|
|
const entries = listConfiguredRuntimeServiceEntries(input.config);
|
|
const states = input.serviceStates ?? readConfiguredServiceStates(input.config);
|
|
const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.defaultDesiredState) ?? "stopped";
|
|
|
|
return entries.filter((_, index) => {
|
|
if (input.serviceIndex !== undefined && input.serviceIndex !== null) {
|
|
return index === input.serviceIndex;
|
|
}
|
|
if (!input.respectDesiredStates) return true;
|
|
return (states[String(index)] ?? fallbackState) === "running";
|
|
});
|
|
}
|
|
|
|
async function isPersistedIsolatedExecutionWorkspace(input: {
|
|
db?: Db;
|
|
companyId: string;
|
|
executionWorkspaceId?: string | null;
|
|
}) {
|
|
if (!input.db || !input.executionWorkspaceId) return false;
|
|
const row = await input.db
|
|
.select({ mode: executionWorkspaces.mode })
|
|
.from(executionWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(executionWorkspaces.id, input.executionWorkspaceId),
|
|
eq(executionWorkspaces.companyId, input.companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
return row?.mode === "isolated_workspace";
|
|
}
|
|
|
|
type EnsureRuntimeServicesForRunInput = {
|
|
db?: Db;
|
|
runId: string;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
workspace: RealizedExecutionWorkspace;
|
|
executionWorkspaceId?: string | null;
|
|
config: Record<string, unknown>;
|
|
adapterEnv: Record<string, string>;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
};
|
|
|
|
async function ensureRuntimeServicesForRunInvocation(
|
|
input: EnsureRuntimeServicesForRunInput,
|
|
): Promise<RuntimeServiceRef[]> {
|
|
const rawServices = selectRuntimeServiceEntries({
|
|
config: input.config,
|
|
respectDesiredStates: true,
|
|
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "running",
|
|
serviceStates: readConfiguredServiceStates(input.config),
|
|
});
|
|
const acquiredServiceIds: string[] = [];
|
|
const refs: RuntimeServiceRef[] = [];
|
|
const runtimeProvision = resolveRuntimeProvision(input);
|
|
const runtimeProvisionCommand = runtimeProvision.command;
|
|
const provisionCoordinator = createRuntimeProvisionCoordinator();
|
|
const allowFixedPortFallback = await isPersistedIsolatedExecutionWorkspace({
|
|
db: input.db,
|
|
companyId: input.agent.companyId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
});
|
|
runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds);
|
|
|
|
try {
|
|
for (const service of rawServices) {
|
|
const { scopeType, scopeId } = resolveServiceScopeId({
|
|
service,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issue: input.issue,
|
|
runId: input.runId,
|
|
agent: input.agent,
|
|
});
|
|
const reuseKey = resolveRuntimeServiceReuseIdentity({
|
|
service,
|
|
workspace: input.workspace,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
scopeType,
|
|
scopeId,
|
|
}).reuseKey;
|
|
|
|
if (reuseKey) {
|
|
const existing = await findHealthyRunningRuntimeService(reuseKey);
|
|
if (existing) {
|
|
existing.leaseRunIds.add(input.runId);
|
|
existing.lastUsedAt = new Date().toISOString();
|
|
existing.stoppedAt = null;
|
|
clearIdleTimer(existing);
|
|
void touchLocalServiceRegistryRecord(existing.serviceKey, {
|
|
runtimeServiceId: existing.id,
|
|
lastSeenAt: existing.lastUsedAt,
|
|
});
|
|
await persistRuntimeServiceRecord(input.db, existing);
|
|
acquiredServiceIds.push(existing.id);
|
|
refs.push(toRuntimeServiceRef(existing, { reused: true }));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const started = await startLocalRuntimeService({
|
|
db: input.db,
|
|
runId: input.runId,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
adapterEnv: input.adapterEnv,
|
|
service,
|
|
onLog: input.onLog,
|
|
runtimeProvisionCommand,
|
|
runtimeProvisionKind: runtimeProvision.kind,
|
|
recorder: input.recorder,
|
|
provisionCoordinator,
|
|
allowFixedPortFallback,
|
|
reuseKey,
|
|
scopeType,
|
|
scopeId,
|
|
});
|
|
const record = started.record;
|
|
registerRuntimeService(input.db, record);
|
|
await persistRuntimeServiceRecord(input.db, record);
|
|
acquiredServiceIds.push(record.id);
|
|
refs.push(toRuntimeServiceRef(record));
|
|
}
|
|
} catch (err) {
|
|
await releaseRuntimeServicesForRun(input.runId);
|
|
throw err;
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
async function withRuntimeStartMutex<T>(ownerKey: string, start: () => Promise<T>): Promise<T> {
|
|
const previous = runtimeControlStartByOwner.get(ownerKey) ?? Promise.resolve();
|
|
let release!: () => void;
|
|
const current = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const queued = previous.then(() => current);
|
|
runtimeControlStartByOwner.set(ownerKey, queued);
|
|
await previous;
|
|
try {
|
|
return await start();
|
|
} finally {
|
|
release();
|
|
if (runtimeControlStartByOwner.get(ownerKey) === queued) runtimeControlStartByOwner.delete(ownerKey);
|
|
}
|
|
}
|
|
|
|
function resolveRuntimeStartMutexPlan(input: {
|
|
services: Array<Record<string, unknown>>;
|
|
workspace: RealizedExecutionWorkspace;
|
|
executionWorkspaceId?: string | null;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
runId: string;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
adapterEnv: Record<string, string>;
|
|
}) {
|
|
const fallbackOwnerId = input.executionWorkspaceId
|
|
?? input.workspace.workspaceId
|
|
?? path.resolve(input.workspace.cwd);
|
|
const replacementReuseKeys: string[] = [];
|
|
const keys = input.services.map((service) => {
|
|
const { scopeType, scopeId } = resolveServiceScopeId({
|
|
service,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issue: input.issue,
|
|
runId: input.runId,
|
|
agent: input.agent,
|
|
});
|
|
const reuseKey = resolveRuntimeServiceReuseIdentity({
|
|
service,
|
|
workspace: input.workspace,
|
|
agent: input.agent,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
scopeType,
|
|
scopeId,
|
|
}).reuseKey;
|
|
// Converge all callers that can replace an existing shared runtime on its
|
|
// reuse identity. For an initial start, retain owner-level concurrency so
|
|
// the exposure allocator's in-flight pair claims remain authoritative.
|
|
if (
|
|
reuseKey
|
|
&& (runtimeServicesByReuseKey.has(reuseKey) || runtimeReplacementClaimsByReuseKey.has(reuseKey))
|
|
) {
|
|
runtimeReplacementClaimsByReuseKey.set(
|
|
reuseKey,
|
|
(runtimeReplacementClaimsByReuseKey.get(reuseKey) ?? 0) + 1,
|
|
);
|
|
replacementReuseKeys.push(reuseKey);
|
|
return `reuse:${reuseKey}`;
|
|
}
|
|
return `${input.agent.companyId}:owner:${fallbackOwnerId}`;
|
|
});
|
|
return {
|
|
ownerKeys: [...new Set(keys)].sort(),
|
|
replacementReuseKeys,
|
|
};
|
|
}
|
|
|
|
function releaseRuntimeReplacementClaims(reuseKeys: string[]) {
|
|
for (const reuseKey of reuseKeys) {
|
|
const next = (runtimeReplacementClaimsByReuseKey.get(reuseKey) ?? 1) - 1;
|
|
if (next <= 0) runtimeReplacementClaimsByReuseKey.delete(reuseKey);
|
|
else runtimeReplacementClaimsByReuseKey.set(reuseKey, next);
|
|
}
|
|
}
|
|
|
|
async function withRuntimeStartMutexes<T>(
|
|
ownerKeys: string[],
|
|
start: () => Promise<T>,
|
|
): Promise<T> {
|
|
const acquire = async (index: number): Promise<T> => {
|
|
const ownerKey = ownerKeys[index];
|
|
if (!ownerKey) return await start();
|
|
return await withRuntimeStartMutex(ownerKey, () => acquire(index + 1));
|
|
};
|
|
return await acquire(0);
|
|
}
|
|
|
|
export async function ensureRuntimeServicesForRun(
|
|
input: EnsureRuntimeServicesForRunInput,
|
|
): Promise<RuntimeServiceRef[]> {
|
|
const services = selectRuntimeServiceEntries({
|
|
config: input.config,
|
|
respectDesiredStates: true,
|
|
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "running",
|
|
serviceStates: readConfiguredServiceStates(input.config),
|
|
});
|
|
const mutexPlan = resolveRuntimeStartMutexPlan({
|
|
services,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issue: input.issue,
|
|
runId: input.runId,
|
|
agent: input.agent,
|
|
adapterEnv: input.adapterEnv,
|
|
});
|
|
try {
|
|
return await withRuntimeStartMutexes(
|
|
mutexPlan.ownerKeys,
|
|
() => ensureRuntimeServicesForRunInvocation(input),
|
|
);
|
|
} finally {
|
|
releaseRuntimeReplacementClaims(mutexPlan.replacementReuseKeys);
|
|
}
|
|
}
|
|
|
|
type StartRuntimeServicesForWorkspaceControlInput = {
|
|
db?: Db;
|
|
invocationId?: string;
|
|
actor: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
workspace: RealizedExecutionWorkspace;
|
|
executionWorkspaceId?: string | null;
|
|
config: Record<string, unknown>;
|
|
adapterEnv: Record<string, string>;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
recorder?: WorkspaceOperationRecorder | null;
|
|
serviceIndex?: number | null;
|
|
runtimeServiceId?: string | null;
|
|
respectDesiredStates?: boolean;
|
|
};
|
|
|
|
type WorkspaceControlStartBatch = {
|
|
refs: RuntimeServiceRef[];
|
|
pendingReadiness: PendingRuntimeServiceReadiness[];
|
|
startedServiceIds: string[];
|
|
};
|
|
|
|
async function startRuntimeServicesForWorkspaceControlUnlocked(
|
|
input: StartRuntimeServicesForWorkspaceControlInput,
|
|
rawServices: Record<string, unknown>[],
|
|
invocationId: string,
|
|
persistenceDb = input.db,
|
|
registryDb = input.db,
|
|
options?: {
|
|
deferReadiness?: boolean;
|
|
allowFixedPortFallback?: boolean;
|
|
runtimeProvisionCommand?: string;
|
|
runtimeProvisionKind?: RuntimeProvisionKind | null;
|
|
provisionCoordinator?: RuntimeProvisionCoordinator;
|
|
preparedProvisioning?: {
|
|
service: Record<string, unknown>;
|
|
record: RuntimeServiceRecord;
|
|
} | null;
|
|
excludedPorts?: ReadonlySet<number>;
|
|
},
|
|
): Promise<WorkspaceControlStartBatch> {
|
|
const refs: RuntimeServiceRef[] = [];
|
|
const pendingReadiness: PendingRuntimeServiceReadiness[] = [];
|
|
const startedServiceIds: string[] = [];
|
|
const requestedRuntimeServiceId = rawServices.length === 1 ? input.runtimeServiceId : null;
|
|
|
|
for (const service of rawServices) {
|
|
const { scopeType, scopeId } = resolveServiceScopeId({
|
|
service,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issue: input.issue,
|
|
runId: invocationId,
|
|
agent: input.actor,
|
|
});
|
|
const reuseKey = resolveRuntimeServiceReuseIdentity({
|
|
service,
|
|
workspace: input.workspace,
|
|
agent: input.actor,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
scopeType,
|
|
scopeId,
|
|
}).reuseKey;
|
|
|
|
if (reuseKey) {
|
|
const existing = await findHealthyRunningRuntimeService(reuseKey);
|
|
if (existing && (!requestedRuntimeServiceId || existing.id === requestedRuntimeServiceId)) {
|
|
const prepared = options?.preparedProvisioning;
|
|
if (prepared?.service === service && prepared.record.id !== existing.id && persistenceDb) {
|
|
await persistenceDb
|
|
.delete(workspaceRuntimeServices)
|
|
.where(eq(workspaceRuntimeServices.id, prepared.record.id));
|
|
}
|
|
existing.lastUsedAt = new Date().toISOString();
|
|
existing.stoppedAt = null;
|
|
clearIdleTimer(existing);
|
|
void touchLocalServiceRegistryRecord(existing.serviceKey, {
|
|
runtimeServiceId: existing.id,
|
|
lastSeenAt: existing.lastUsedAt,
|
|
});
|
|
await persistRuntimeServiceRecord(persistenceDb, existing);
|
|
refs.push(toRuntimeServiceRef(existing, { reused: true }));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const startInput: StartLocalRuntimeServiceInput = {
|
|
db: persistenceDb,
|
|
runId: invocationId,
|
|
leaseRunId: null,
|
|
startedByRunId: null,
|
|
agent: input.actor,
|
|
issue: input.issue,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
adapterEnv: input.adapterEnv,
|
|
service,
|
|
onLog: input.onLog,
|
|
runtimeProvisionCommand: options?.runtimeProvisionCommand,
|
|
runtimeProvisionKind: options?.runtimeProvisionKind,
|
|
recorder: input.recorder,
|
|
provisionCoordinator: options?.provisionCoordinator,
|
|
preparedProvisioningRecord:
|
|
options?.preparedProvisioning?.service === service
|
|
? options.preparedProvisioning.record
|
|
: undefined,
|
|
allowFixedPortFallback: options?.allowFixedPortFallback,
|
|
excludedPorts: options?.excludedPorts,
|
|
runtimeServiceId: requestedRuntimeServiceId ?? undefined,
|
|
reuseKey,
|
|
scopeType,
|
|
scopeId,
|
|
};
|
|
|
|
// Manually controlled services are not tied to a heartbeat run lifecycle, so they do not
|
|
// retain a run lease and never persist a startedByRunId foreign key.
|
|
const started = await startLocalRuntimeService(startInput, {
|
|
deferReadiness: options?.deferReadiness,
|
|
});
|
|
registerRuntimeService(registryDb, started.record);
|
|
await persistRuntimeServiceRecord(persistenceDb, started.record);
|
|
refs.push(toRuntimeServiceRef(started.record));
|
|
|
|
if (options?.deferReadiness && started.record.status === "starting" && !started.record.reused) {
|
|
// Attach a rejection handler immediately; the caller awaits the same promise after
|
|
// the DB transaction commits, but transaction failures may skip that wait path.
|
|
started.readiness.catch(() => undefined);
|
|
pendingReadiness.push({ ...started, service });
|
|
startedServiceIds.push(started.record.id);
|
|
}
|
|
}
|
|
|
|
return { refs, pendingReadiness, startedServiceIds };
|
|
}
|
|
|
|
async function lockWorkspaceRuntimeStartParents(
|
|
db: Db,
|
|
input: StartRuntimeServicesForWorkspaceControlInput,
|
|
) {
|
|
let allowFixedPortFallback = false;
|
|
if (input.executionWorkspaceId) {
|
|
const [lockedExecutionWorkspace] = await db
|
|
.select({ id: executionWorkspaces.id, mode: executionWorkspaces.mode })
|
|
.from(executionWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(executionWorkspaces.id, input.executionWorkspaceId),
|
|
eq(executionWorkspaces.companyId, input.actor.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (!lockedExecutionWorkspace) throw new Error("Execution workspace not found before starting runtime services");
|
|
allowFixedPortFallback = lockedExecutionWorkspace.mode === "isolated_workspace";
|
|
}
|
|
|
|
if (input.workspace.workspaceId) {
|
|
const [lockedProjectWorkspace] = await db
|
|
.select({ id: projectWorkspaces.id })
|
|
.from(projectWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(projectWorkspaces.id, input.workspace.workspaceId),
|
|
eq(projectWorkspaces.companyId, input.actor.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (!lockedProjectWorkspace) throw new Error("Project workspace not found before starting runtime services");
|
|
}
|
|
|
|
return allowFixedPortFallback;
|
|
}
|
|
|
|
function canRetryDeferredPortBindCollision(
|
|
service: Record<string, unknown>,
|
|
allowFixedPortFallback: boolean,
|
|
) {
|
|
const portConfig = parseObject(service.port);
|
|
const portType = asString(portConfig.type, "");
|
|
const explicitPort = asNumber(portConfig.value, asNumber(service.port, 0));
|
|
return portType === "auto" || Boolean(allowFixedPortFallback && explicitPort > 0);
|
|
}
|
|
|
|
async function discardFailedDeferredRuntimeStart(db: Db, record: RuntimeServiceRecord) {
|
|
clearIdleTimer(record);
|
|
if (runtimeServicesById.get(record.id) === record) runtimeServicesById.delete(record.id);
|
|
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
|
|
runtimeServicesByReuseKey.delete(record.reuseKey);
|
|
}
|
|
await removeLocalServiceRegistryRecord(record.serviceKey).catch(() => undefined);
|
|
await persistRuntimeServiceRecord(db, record);
|
|
}
|
|
|
|
async function startRuntimeServicesForWorkspaceControlInvocation(
|
|
input: StartRuntimeServicesForWorkspaceControlInput,
|
|
): Promise<RuntimeServiceRef[]> {
|
|
const rawServices = selectRuntimeServiceEntries({
|
|
config: input.config,
|
|
serviceIndex: input.serviceIndex,
|
|
respectDesiredStates: input.respectDesiredStates,
|
|
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped",
|
|
serviceStates: readConfiguredServiceStates(input.config),
|
|
});
|
|
const invocationId = input.invocationId ?? randomUUID();
|
|
const runtimeProvision = resolveRuntimeProvision(input);
|
|
const runtimeProvisionCommand = runtimeProvision.command;
|
|
const provisionCoordinator = createRuntimeProvisionCoordinator();
|
|
const hasHttpsExposure = await anyRuntimeServiceUsesHttpsExposure(rawServices);
|
|
|
|
if (
|
|
rawServices.length === 0
|
|
|| !input.db
|
|
|| (!input.executionWorkspaceId && !input.workspace.workspaceId)
|
|
// The reservation row must commit before the backend binds. Keeping this
|
|
// path outside the parent-row transaction avoids a crash window where the
|
|
// broker lease exists but the DB transaction has not committed its handle.
|
|
|| hasHttpsExposure
|
|
) {
|
|
const batch = await startRuntimeServicesForWorkspaceControlUnlocked(
|
|
input,
|
|
rawServices,
|
|
invocationId,
|
|
input.db,
|
|
input.db,
|
|
{
|
|
runtimeProvisionCommand,
|
|
runtimeProvisionKind: runtimeProvision.kind,
|
|
provisionCoordinator,
|
|
},
|
|
);
|
|
return batch.refs;
|
|
}
|
|
|
|
let startBatch: WorkspaceControlStartBatch = {
|
|
refs: [],
|
|
pendingReadiness: [],
|
|
startedServiceIds: [],
|
|
};
|
|
let preparedProvisioning: {
|
|
service: Record<string, unknown>;
|
|
record: RuntimeServiceRecord;
|
|
} | null = null;
|
|
let allowFixedPortFallback = false;
|
|
try {
|
|
if (runtimeProvisionCommand) {
|
|
for (const service of rawServices) {
|
|
const { scopeType, scopeId } = resolveServiceScopeId({
|
|
service,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issue: input.issue,
|
|
runId: invocationId,
|
|
agent: input.actor,
|
|
});
|
|
const reuseKey = resolveRuntimeServiceReuseIdentity({
|
|
service,
|
|
workspace: input.workspace,
|
|
agent: input.actor,
|
|
issue: input.issue,
|
|
adapterEnv: input.adapterEnv,
|
|
scopeType,
|
|
scopeId,
|
|
}).reuseKey;
|
|
const existing = await findHealthyRunningRuntimeService(reuseKey);
|
|
if (existing) continue;
|
|
|
|
const record = await prepareRuntimeProvisioning({
|
|
db: input.db,
|
|
runId: invocationId,
|
|
leaseRunId: null,
|
|
startedByRunId: null,
|
|
agent: input.actor,
|
|
issue: input.issue,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
adapterEnv: input.adapterEnv,
|
|
service,
|
|
onLog: input.onLog,
|
|
runtimeProvisionCommand,
|
|
runtimeProvisionKind: runtimeProvision.kind,
|
|
recorder: input.recorder,
|
|
provisionCoordinator,
|
|
reuseKey,
|
|
scopeType,
|
|
scopeId,
|
|
});
|
|
if (record) preparedProvisioning = { service, record };
|
|
break;
|
|
}
|
|
}
|
|
|
|
await input.db.transaction(async (tx) => {
|
|
const txDb = tx as unknown as Db;
|
|
allowFixedPortFallback = await lockWorkspaceRuntimeStartParents(txDb, input);
|
|
|
|
// Branch reconciliation takes these same parent row locks before mutating
|
|
// a recorded branch. Persisting a `starting` service row before commit closes
|
|
// the process-start window without holding the DB transaction for readiness.
|
|
startBatch = await startRuntimeServicesForWorkspaceControlUnlocked(
|
|
{ ...input, db: txDb },
|
|
rawServices,
|
|
invocationId,
|
|
txDb,
|
|
input.db,
|
|
{
|
|
deferReadiness: true,
|
|
allowFixedPortFallback,
|
|
runtimeProvisionCommand,
|
|
runtimeProvisionKind: runtimeProvision.kind,
|
|
provisionCoordinator,
|
|
preparedProvisioning,
|
|
},
|
|
);
|
|
});
|
|
|
|
// Readiness never uses the transaction-scoped DB handle. A late bind collision is
|
|
// recorded after commit, then only the next bounded reservation re-enters a short
|
|
// parent-locked transaction. Slow builds therefore cannot retain the parent locks.
|
|
for (const initialPending of startBatch.pendingReadiness) {
|
|
let pending: PendingRuntimeServiceReadiness | null = initialPending;
|
|
const excludedPorts = new Set<number>();
|
|
|
|
while (pending) {
|
|
try {
|
|
await pending.readiness;
|
|
await persistRuntimeServiceRecord(input.db, pending.record);
|
|
break;
|
|
} catch (error) {
|
|
await discardFailedDeferredRuntimeStart(input.db, pending.record);
|
|
if (
|
|
!(error instanceof RuntimeServicePortBindCollision)
|
|
|| !canRetryDeferredPortBindCollision(pending.service, allowFixedPortFallback)
|
|
) {
|
|
throw error;
|
|
}
|
|
|
|
excludedPorts.add(error.port);
|
|
if (excludedPorts.size >= WORKSPACE_RUNTIME_PORT_ALLOCATION_ATTEMPTS) {
|
|
const portConfig = parseObject(pending.service.port);
|
|
const portType = asString(portConfig.type, "");
|
|
const preferredPort = asNumber(portConfig.value, asNumber(pending.service.port, 0));
|
|
if (allowFixedPortFallback && portType !== "auto" && preferredPort > 0 && input.executionWorkspaceId) {
|
|
throw await buildRuntimePortAllocationConflict({
|
|
db: input.db,
|
|
companyId: input.actor.companyId,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
preferredPort,
|
|
attemptedPorts: [...excludedPorts],
|
|
});
|
|
}
|
|
throw conflict("No safe automatically allocated runtime service port is available.", {
|
|
code: "workspace_runtime_port_allocation_exhausted",
|
|
attemptedPortCount: excludedPorts.size,
|
|
remediation: "Retry the start or configure a different runtime service port.",
|
|
});
|
|
}
|
|
|
|
const failedRecordId = pending.record.id;
|
|
let retryBatch: WorkspaceControlStartBatch = {
|
|
refs: [],
|
|
pendingReadiness: [],
|
|
startedServiceIds: [],
|
|
};
|
|
let retryAllowsFixedPortFallback = false;
|
|
await input.db.transaction(async (tx) => {
|
|
const txDb = tx as unknown as Db;
|
|
retryAllowsFixedPortFallback = await lockWorkspaceRuntimeStartParents(txDb, input);
|
|
if (!canRetryDeferredPortBindCollision(pending!.service, retryAllowsFixedPortFallback)) {
|
|
throw error;
|
|
}
|
|
retryBatch = await startRuntimeServicesForWorkspaceControlUnlocked(
|
|
{ ...input, db: txDb },
|
|
[pending!.service],
|
|
invocationId,
|
|
txDb,
|
|
input.db,
|
|
{
|
|
deferReadiness: true,
|
|
allowFixedPortFallback: retryAllowsFixedPortFallback,
|
|
provisionCoordinator,
|
|
excludedPorts,
|
|
},
|
|
);
|
|
});
|
|
allowFixedPortFallback = retryAllowsFixedPortFallback;
|
|
for (const serviceId of retryBatch.startedServiceIds) {
|
|
if (!startBatch.startedServiceIds.includes(serviceId)) {
|
|
startBatch.startedServiceIds.push(serviceId);
|
|
}
|
|
}
|
|
const replacementRef = retryBatch.refs[0];
|
|
const failedRefIndex = startBatch.refs.findIndex((ref) => ref.id === failedRecordId);
|
|
if (replacementRef && failedRefIndex >= 0) startBatch.refs[failedRefIndex] = replacementRef;
|
|
pending = retryBatch.pendingReadiness[0] ?? null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return startBatch.refs.map((ref) => {
|
|
const record = runtimeServicesById.get(ref.id);
|
|
return record ? toRuntimeServiceRef(record, { reused: ref.reused }) : ref;
|
|
});
|
|
} catch (error) {
|
|
for (const serviceId of startBatch.startedServiceIds) {
|
|
await stopRuntimeService(serviceId).catch(() => undefined);
|
|
}
|
|
if (preparedProvisioning && startBatch.startedServiceIds.length === 0) {
|
|
const nowIso = new Date().toISOString();
|
|
preparedProvisioning.record.status = "failed";
|
|
preparedProvisioning.record.healthStatus = "unhealthy";
|
|
preparedProvisioning.record.lastUsedAt = nowIso;
|
|
preparedProvisioning.record.stoppedAt = nowIso;
|
|
await persistRuntimeServiceRecord(input.db, preparedProvisioning.record).catch(() => undefined);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function startRuntimeServicesForWorkspaceControl(
|
|
input: StartRuntimeServicesForWorkspaceControlInput,
|
|
): Promise<RuntimeServiceRef[]> {
|
|
const services = selectRuntimeServiceEntries({
|
|
config: input.config,
|
|
serviceIndex: input.serviceIndex,
|
|
respectDesiredStates: input.respectDesiredStates,
|
|
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped",
|
|
serviceStates: readConfiguredServiceStates(input.config),
|
|
});
|
|
const invocationId = input.invocationId ?? "workspace_control";
|
|
const mutexPlan = resolveRuntimeStartMutexPlan({
|
|
services,
|
|
workspace: input.workspace,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
issue: input.issue,
|
|
runId: invocationId,
|
|
agent: input.actor,
|
|
adapterEnv: input.adapterEnv,
|
|
});
|
|
try {
|
|
return await withRuntimeStartMutexes(
|
|
mutexPlan.ownerKeys,
|
|
() => startRuntimeServicesForWorkspaceControlInvocation(input),
|
|
);
|
|
} finally {
|
|
releaseRuntimeReplacementClaims(mutexPlan.replacementReuseKeys);
|
|
}
|
|
}
|
|
|
|
export async function releaseRuntimeServicesForRun(runId: string) {
|
|
const acquired = runtimeServiceLeasesByRun.get(runId) ?? [];
|
|
runtimeServiceLeasesByRun.delete(runId);
|
|
for (const serviceId of acquired) {
|
|
const record = runtimeServicesById.get(serviceId);
|
|
if (!record) continue;
|
|
record.leaseRunIds.delete(runId);
|
|
record.lastUsedAt = new Date().toISOString();
|
|
const stopType = asString(record.stopPolicy?.type, record.lifecycle === "ephemeral" ? "on_run_finish" : "manual");
|
|
await persistRuntimeServiceRecord(record.db, record);
|
|
if (record.leaseRunIds.size === 0) {
|
|
const detachedUnhealthySharedRuntime = record.healthStatus === "unhealthy"
|
|
&& Boolean(record.reuseKey)
|
|
&& runtimeServicesByReuseKey.get(record.reuseKey!) !== record.id;
|
|
if (
|
|
record.lifecycle === "ephemeral"
|
|
|| stopType === "on_run_finish"
|
|
|| detachedUnhealthySharedRuntime
|
|
) {
|
|
await stopRuntimeService(serviceId);
|
|
continue;
|
|
}
|
|
scheduleIdleStop(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function stopRuntimeServicesForExecutionWorkspace(input: {
|
|
db?: Db;
|
|
executionWorkspaceId: string;
|
|
workspaceCwd?: string | null;
|
|
runtimeServiceId?: string | null;
|
|
}) {
|
|
const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null;
|
|
const matchingServiceIds = Array.from(runtimeServicesById.values())
|
|
.filter((record) => {
|
|
if (input.runtimeServiceId) return record.id === input.runtimeServiceId;
|
|
if (record.executionWorkspaceId === input.executionWorkspaceId) return true;
|
|
if (!normalizedWorkspaceCwd || !record.cwd) return false;
|
|
const resolvedCwd = path.resolve(record.cwd);
|
|
return (
|
|
resolvedCwd === normalizedWorkspaceCwd ||
|
|
resolvedCwd.startsWith(`${normalizedWorkspaceCwd}${path.sep}`)
|
|
);
|
|
})
|
|
.map((record) => record.id);
|
|
|
|
for (const serviceId of matchingServiceIds) {
|
|
await stopRuntimeService(serviceId);
|
|
}
|
|
|
|
if (input.db) {
|
|
if (input.runtimeServiceId) {
|
|
const now = new Date();
|
|
const rows = await input.db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
exposure: workspaceRuntimeServices.exposure,
|
|
exposureHandle: workspaceRuntimeServices.exposureHandle,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(eq(workspaceRuntimeServices.id, input.runtimeServiceId));
|
|
await cleanupPersistedExposureRows(input.db, rows);
|
|
await input.db
|
|
.update(workspaceRuntimeServices)
|
|
.set({
|
|
status: "stopped",
|
|
healthStatus: "unknown",
|
|
stoppedAt: now,
|
|
lastUsedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(workspaceRuntimeServices.id, input.runtimeServiceId));
|
|
} else {
|
|
await markPersistedRuntimeServicesStoppedForExecutionWorkspace({
|
|
db: input.db,
|
|
executionWorkspaceId: input.executionWorkspaceId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function stopRuntimeServicesForProjectWorkspace(input: {
|
|
db?: Db;
|
|
projectWorkspaceId: string;
|
|
runtimeServiceId?: string | null;
|
|
}) {
|
|
const matchingServiceIds = Array.from(runtimeServicesById.values())
|
|
.filter((record) => {
|
|
if (input.runtimeServiceId) return record.id === input.runtimeServiceId;
|
|
return record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace";
|
|
})
|
|
.map((record) => record.id);
|
|
|
|
for (const serviceId of matchingServiceIds) {
|
|
await stopRuntimeService(serviceId);
|
|
}
|
|
|
|
if (input.db) {
|
|
const now = new Date();
|
|
const exposureCondition = input.runtimeServiceId
|
|
? eq(workspaceRuntimeServices.id, input.runtimeServiceId)
|
|
: and(
|
|
eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId),
|
|
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
|
inArray(workspaceRuntimeServices.status, ["provisioning", "starting", "running"]),
|
|
);
|
|
const exposureRows = await input.db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
exposure: workspaceRuntimeServices.exposure,
|
|
exposureHandle: workspaceRuntimeServices.exposureHandle,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(exposureCondition);
|
|
await cleanupPersistedExposureRows(input.db, exposureRows);
|
|
await input.db
|
|
.update(workspaceRuntimeServices)
|
|
.set({
|
|
status: "stopped",
|
|
healthStatus: "unknown",
|
|
stoppedAt: now,
|
|
lastUsedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
exposureCondition,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
|
|
db: Db,
|
|
companyId: string,
|
|
projectWorkspaceIds: string[],
|
|
) {
|
|
if (projectWorkspaceIds.length === 0) return new Map<string, typeof workspaceRuntimeServices.$inferSelect[]>();
|
|
const rows = await db
|
|
.select()
|
|
.from(workspaceRuntimeServices)
|
|
.where(
|
|
and(
|
|
eq(workspaceRuntimeServices.companyId, companyId),
|
|
inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds),
|
|
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
|
),
|
|
)
|
|
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
|
|
|
const grouped = new Map<string, typeof workspaceRuntimeServices.$inferSelect[]>();
|
|
for (const row of rows) {
|
|
if (!row.projectWorkspaceId) continue;
|
|
const existing = grouped.get(row.projectWorkspaceId);
|
|
if (existing) existing.push(row);
|
|
else grouped.set(row.projectWorkspaceId, [row]);
|
|
}
|
|
return grouped;
|
|
}
|
|
|
|
/**
|
|
* Statuses that mean "there is, or is supposed to be, a live backend process".
|
|
* A row in one of these states is what the backfill has to reprovision; a
|
|
* `stopped` row simply picks the default up on its next start.
|
|
*/
|
|
const LIVE_RUNTIME_SERVICE_STATUSES = new Set(["provisioning", "starting", "running"]);
|
|
|
|
export type ManagedRuntimeExposureBackfillDecision = {
|
|
action: "keep" | "reprovision";
|
|
reason: string;
|
|
};
|
|
|
|
/**
|
|
* Decide what the HTTPS backfill should do with one persisted runtime-service
|
|
* row (PAP-17158).
|
|
*
|
|
* Pure so every branch is directly testable: the reasons below are the whole
|
|
* contract for which pre-feature workspaces get upgraded and which are left
|
|
* exactly as they are.
|
|
*
|
|
* `declaredIntent === null` means no configured service entry could be matched
|
|
* to this row. Such a row is deliberately left alone: reprovisioning works by
|
|
* stopping the HTTP backend and letting the desired-state restart bring it back
|
|
* with exposure, so without a config to restart from we would take a service
|
|
* down and never bring it back.
|
|
*/
|
|
export function decideManagedRuntimeExposureBackfill(input: {
|
|
mode: ManagedRuntimeHttpsMode;
|
|
brokerAvailable: boolean;
|
|
provider: string;
|
|
serviceName: string;
|
|
command: string | null;
|
|
status: string;
|
|
hasExposure: boolean;
|
|
declaredIntent: RuntimeExposureIntent | null;
|
|
}): ManagedRuntimeExposureBackfillDecision {
|
|
if (input.mode === "off") return { action: "keep", reason: "https_default_disabled" };
|
|
if (input.provider !== "local_process") return { action: "keep", reason: "not_a_managed_local_process" };
|
|
// Idempotence: a row that already carries exposure state is never re-driven,
|
|
// so repeated deploys and restarts converge instead of churning listeners.
|
|
if (input.hasExposure) return { action: "keep", reason: "already_exposed" };
|
|
if (input.declaredIntent === "disabled") return { action: "keep", reason: "deliberate_opt_out" };
|
|
if (input.declaredIntent === null) return { action: "keep", reason: "no_configured_service_entry" };
|
|
if (!isManagedHttpsDefaultCandidate({ serviceName: input.serviceName, command: input.command })) {
|
|
return { action: "keep", reason: "unmanaged_or_custom_service" };
|
|
}
|
|
if (input.mode !== "force" && !input.brokerAvailable) {
|
|
return { action: "keep", reason: "broker_unavailable" };
|
|
}
|
|
if (!LIVE_RUNTIME_SERVICE_STATUSES.has(input.status)) {
|
|
return { action: "keep", reason: "stopped_defaults_on_next_start" };
|
|
}
|
|
return { action: "reprovision", reason: "http_only_managed_service" };
|
|
}
|
|
|
|
/**
|
|
* Look up the exposure intent a persisted runtime-service row inherits from its
|
|
* owning workspace configuration, by matching the row's service name against the
|
|
* configured entries. Returns null when no entry matches.
|
|
*/
|
|
async function buildPersistedRuntimeExposureIntentLookup(db: Db) {
|
|
const [projectWorkspaceRows, executionWorkspaceRows] = await Promise.all([
|
|
db.select().from(projectWorkspaces),
|
|
db.select().from(executionWorkspaces),
|
|
]);
|
|
const projectRuntimeById = new Map(projectWorkspaceRows.map((row) => [
|
|
row.id,
|
|
readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime ?? null,
|
|
] as const));
|
|
const executionRuntimeById = new Map(executionWorkspaceRows.map((row) => [
|
|
row.id,
|
|
readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime
|
|
?? (row.projectWorkspaceId ? projectRuntimeById.get(row.projectWorkspaceId) ?? null : null),
|
|
] as const));
|
|
|
|
return (row: {
|
|
serviceName: string;
|
|
projectWorkspaceId: string | null;
|
|
executionWorkspaceId: string | null;
|
|
}): RuntimeExposureIntent | null => {
|
|
const runtime = (row.executionWorkspaceId ? executionRuntimeById.get(row.executionWorkspaceId) : null)
|
|
?? (row.projectWorkspaceId ? projectRuntimeById.get(row.projectWorkspaceId) ?? null : null);
|
|
if (!runtime) return null;
|
|
const entries = listConfiguredRuntimeServiceEntries({ workspaceRuntime: runtime });
|
|
const entry = entries.find((candidate) => asString(candidate.name, "service") === row.serviceName);
|
|
if (!entry) return null;
|
|
return readRuntimeExposureIntent(parseObject(entry.expose));
|
|
};
|
|
}
|
|
|
|
export async function refreshPersistedRuntimeServiceHealth(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
executionWorkspaceId: string;
|
|
projectWorkspaceId?: string | null;
|
|
}) {
|
|
const ownershipCondition = input.projectWorkspaceId
|
|
? or(
|
|
eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId),
|
|
and(
|
|
eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId),
|
|
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
|
),
|
|
)
|
|
: eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId);
|
|
const rows = await input.db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
serviceName: workspaceRuntimeServices.serviceName,
|
|
command: workspaceRuntimeServices.command,
|
|
provider: workspaceRuntimeServices.provider,
|
|
port: workspaceRuntimeServices.port,
|
|
url: workspaceRuntimeServices.url,
|
|
healthStatus: workspaceRuntimeServices.healthStatus,
|
|
cwd: workspaceRuntimeServices.cwd,
|
|
executionWorkspaceId: workspaceRuntimeServices.executionWorkspaceId,
|
|
companyId: workspaceRuntimeServices.companyId,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(and(
|
|
eq(workspaceRuntimeServices.companyId, input.companyId),
|
|
eq(workspaceRuntimeServices.provider, "local_process"),
|
|
eq(workspaceRuntimeServices.status, "running"),
|
|
ownershipCondition,
|
|
));
|
|
const results = await Promise.all(rows.map(async (row) => ({
|
|
row,
|
|
healthStatus: await isRuntimeServiceUrlHealthy(row.url, { ...row, db: input.db })
|
|
? "healthy" as const
|
|
: "unhealthy" as const,
|
|
})));
|
|
await Promise.all(results.map(async ({ row, healthStatus }) => {
|
|
const liveRecord = runtimeServicesById.get(row.id);
|
|
if (liveRecord) liveRecord.healthStatus = healthStatus;
|
|
if (row.healthStatus === healthStatus) return;
|
|
await input.db.update(workspaceRuntimeServices).set({ healthStatus, updatedAt: new Date() }).where(and(
|
|
eq(workspaceRuntimeServices.id, row.id),
|
|
eq(workspaceRuntimeServices.companyId, input.companyId),
|
|
eq(workspaceRuntimeServices.status, "running"),
|
|
));
|
|
}));
|
|
return {
|
|
checked: results.length,
|
|
healthy: results.filter((result) => result.healthStatus === "healthy").length,
|
|
unhealthy: results.filter((result) => result.healthStatus === "unhealthy").length,
|
|
};
|
|
}
|
|
|
|
export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
|
const rows = await db
|
|
.select()
|
|
.from(workspaceRuntimeServices)
|
|
.where(
|
|
and(
|
|
eq(workspaceRuntimeServices.provider, "local_process"),
|
|
inArray(workspaceRuntimeServices.status, ["provisioning", "starting", "running", "stopped"]),
|
|
),
|
|
);
|
|
|
|
// Backfill inputs, resolved once per startup rather than per row.
|
|
const httpsMode = resolveManagedRuntimeHttpsMode();
|
|
const brokerAvailable = httpsMode === "off"
|
|
? false
|
|
: await workspaceRuntimeExposureDeps.isBrokerAvailable().catch(() => false);
|
|
const readDeclaredExposureIntent = await buildPersistedRuntimeExposureIntentLookup(db);
|
|
|
|
let ownedExposureListeners: Awaited<ReturnType<BrokerClient["list"]>> | null = [];
|
|
// Also fetch when a row merely *reserves* a dedicated-range port. The row that
|
|
// matters most to PAP-17419 is exactly the one with `exposure.state ===
|
|
// "removed"`: it claims nothing, yet its leased pair can still be mapped by
|
|
// someone else. Skipping the broker read for those rows is what let a false
|
|
// `removed` go unnoticed.
|
|
if (rows.some((row) => (
|
|
(row.exposure && row.exposure.state !== "removed")
|
|
|| (row.port !== null && isRuntimeExposureAppPort(row.port))
|
|
))) {
|
|
try {
|
|
ownedExposureListeners = await workspaceRuntimeExposureDeps.broker.list();
|
|
} catch {
|
|
ownedExposureListeners = null;
|
|
}
|
|
}
|
|
|
|
const exposureReservationDrift = await detectPersistedExposureReservationDrift({
|
|
rows,
|
|
ownedListeners: ownedExposureListeners,
|
|
});
|
|
const companyIdByRowId = new Map(rows.map((row) => [row.id, row.companyId] as const));
|
|
for (const entry of exposureReservationDrift) {
|
|
const description = describeExposureReservationDrift(entry);
|
|
console.warn(`[workspace-runtime] exposure reservation drift: ${description}`);
|
|
const companyId = companyIdByRowId.get(entry.runtimeServiceId);
|
|
if (!companyId) continue;
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: "system",
|
|
actorId: "workspace_runtime",
|
|
action: "workspace_runtime.exposure_reservation_drift",
|
|
entityType: entry.owner.executionWorkspaceId ? "execution_workspace" : "workspace_runtime_service",
|
|
entityId: entry.owner.executionWorkspaceId ?? entry.runtimeServiceId,
|
|
issueId: entry.owner.issueId,
|
|
details: {
|
|
description,
|
|
runtimeServiceId: entry.runtimeServiceId,
|
|
port: entry.port,
|
|
reason: entry.reason,
|
|
executionWorkspaceId: entry.owner.executionWorkspaceId,
|
|
conflictingExecutionWorkspaceId: entry.conflictingOwner?.executionWorkspaceId ?? null,
|
|
conflictingRuntimeServiceId: entry.conflictingOwner?.runtimeServiceId ?? null,
|
|
},
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
let reconciled = 0;
|
|
let adopted = 0;
|
|
let stopped = 0;
|
|
let backfilled = 0;
|
|
const driftedRuntimeServiceIds = new Set(exposureReservationDrift.map((entry) => entry.runtimeServiceId));
|
|
for (const row of rows) {
|
|
// PAP-17419: this row's reserved pair is live, or Serve-mapped, under an
|
|
// identity that is not this row's. Every branch below is unsafe for such a
|
|
// row — cleanup would remove a mapping that is now someone else's, adoption
|
|
// would take over another execution workspace's service and re-attribute it
|
|
// here, and the health branch would terminate it outright. None of that is
|
|
// this sweep's call to make, so leave the row and the occupying service
|
|
// exactly as they are. The drift is already reported above, and the ledger
|
|
// keeps the pair reserved so no start can be handed it either.
|
|
if (driftedRuntimeServiceIds.has(row.id)) {
|
|
reconciled += 1;
|
|
continue;
|
|
}
|
|
if (row.status === "stopped" && row.exposure && row.exposure.state !== "removed") {
|
|
// This branch is a GLOBAL sweep: `rows` spans every execution workspace and
|
|
// company on the host, at any age, and it runs on every server start. It
|
|
// used to issue a broker removal for each stale row on the row's authority
|
|
// alone — which is how a restart at 12:25:49 UTC deleted the preserved
|
|
// `42000/52000` mappings from two rows that were 2 and 3 days old
|
|
// (PAP-17285). Corroborate against the broker's live ownership view, which
|
|
// this function has already fetched, instead of trusting the row.
|
|
await cleanupPersistedExposureRows(
|
|
db,
|
|
[{ id: row.id, exposure: row.exposure, exposureHandle: row.exposureHandle }],
|
|
{ ownedListeners: ownedExposureListeners },
|
|
);
|
|
reconciled += 1;
|
|
continue;
|
|
}
|
|
const rowExposureListeners = ownedExposureListeners?.filter((listener) => listener.runtimeId === row.id) ?? [];
|
|
const exposureMappingMatches = !row.exposure || (
|
|
row.exposure.state === "ready"
|
|
&& Boolean(row.exposureHandle)
|
|
&& rowExposureListeners.length === row.exposure.listeners.length
|
|
&& row.exposure.listeners.every((expected) => rowExposureListeners.some((actual) => (
|
|
actual.port === expected.targetPort && actual.purpose === expected.purpose
|
|
)))
|
|
);
|
|
const exposureHealthMatches = !row.exposure || (
|
|
exposureMappingMatches
|
|
&& Boolean(row.exposure.publicUrl)
|
|
&& await workspaceRuntimeExposureDeps.probeHealth(
|
|
new URL("/api/health", row.exposure.publicUrl!).toString(),
|
|
)
|
|
);
|
|
// Pre-feature rows carry no exposure state at all. An eligible one that is
|
|
// still serving plain HTTP must not be adopted as-is, or the deploy would
|
|
// leave `http://paperclip-dev:<port>` as the canonical URL forever. Stopping
|
|
// it here hands it to the desired-state restart below, which brings it back
|
|
// through the normal fail-closed exposure lifecycle.
|
|
const backfillDecision = decideManagedRuntimeExposureBackfill({
|
|
mode: httpsMode,
|
|
brokerAvailable,
|
|
provider: row.provider,
|
|
serviceName: row.serviceName,
|
|
command: row.command,
|
|
status: row.status,
|
|
hasExposure: Boolean(row.exposure && row.exposure.state !== "removed"),
|
|
declaredIntent: readDeclaredExposureIntent({
|
|
serviceName: row.serviceName,
|
|
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
|
}),
|
|
});
|
|
let adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
|
|
runtimeServiceId: row.id,
|
|
profileKind: "workspace-runtime",
|
|
});
|
|
if (
|
|
adoptedRecord
|
|
&& (
|
|
adoptedRecord.command !== row.command
|
|
|| adoptedRecord.serviceName !== row.serviceName
|
|
|| adoptedRecord.envFingerprint !== (row.reuseKey ?? "")
|
|
|| adoptedRecord.port !== (row.port ?? null)
|
|
|| (row.cwd !== null && path.resolve(adoptedRecord.cwd) !== path.resolve(row.cwd))
|
|
)
|
|
) {
|
|
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
|
adoptedRecord = null;
|
|
}
|
|
if (!adoptedRecord && row.command && row.cwd) {
|
|
adoptedRecord = await findAdoptableLocalService({
|
|
serviceKey: createLocalServiceKey({
|
|
profileKind: "workspace-runtime",
|
|
serviceName: row.serviceName,
|
|
cwd: row.cwd,
|
|
command: row.command,
|
|
envFingerprint: row.reuseKey ?? "",
|
|
port: null,
|
|
scope: {
|
|
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
|
scopeId: row.scopeId ?? null,
|
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
|
reuseKey: row.reuseKey ?? null,
|
|
},
|
|
}),
|
|
profileKind: "workspace-runtime",
|
|
serviceName: row.serviceName,
|
|
command: row.command,
|
|
cwd: row.cwd,
|
|
envFingerprint: row.reuseKey ?? "",
|
|
port: row.port ?? null,
|
|
url: row.backendUrl ?? row.url ?? null,
|
|
});
|
|
}
|
|
if (adoptedRecord) {
|
|
const adoptedUrl = adoptedRecord.url ?? row.backendUrl ?? row.url ?? null;
|
|
const adoptedHealthInput = {
|
|
db,
|
|
serviceName: row.serviceName,
|
|
command: row.command,
|
|
provider: "local_process",
|
|
port: adoptedRecord.port ?? row.port,
|
|
cwd: row.cwd,
|
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
|
companyId: row.companyId,
|
|
};
|
|
// A surviving service can be slow to answer one probe when the host is
|
|
// busy at startup. One timeout is not enough evidence to terminate it.
|
|
// Confirm an unhealthy verdict with a second probe after a short bounded
|
|
// delay, the same way the reuse path protects a shared runtime.
|
|
let adoptedHealthy = await isRuntimeServiceUrlHealthy(adoptedUrl, adoptedHealthInput);
|
|
if (!adoptedHealthy) {
|
|
await delay(250);
|
|
adoptedHealthy = await isRuntimeServiceUrlHealthy(adoptedUrl, adoptedHealthInput);
|
|
}
|
|
if (
|
|
backfillDecision.action === "reprovision"
|
|
|| !exposureHealthMatches
|
|
|| !adoptedHealthy
|
|
) {
|
|
if (backfillDecision.action === "reprovision") backfilled += 1;
|
|
await terminateLocalService(adoptedRecord);
|
|
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
|
} else {
|
|
const record: RuntimeServiceRecord = {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
projectId: row.projectId ?? null,
|
|
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
|
issueId: row.issueId ?? null,
|
|
serviceName: row.serviceName,
|
|
status: "running",
|
|
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
|
|
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
|
scopeId: row.scopeId ?? null,
|
|
reuseKey: row.reuseKey ?? null,
|
|
command: row.command ?? null,
|
|
cwd: row.cwd ?? null,
|
|
port: adoptedRecord.port ?? row.port ?? null,
|
|
url: row.exposure?.publicUrl ?? adoptedRecord.url ?? row.url ?? null,
|
|
provider: "local_process",
|
|
providerRef: String(adoptedRecord.pid),
|
|
ownerAgentId: row.ownerAgentId ?? null,
|
|
startedByRunId: row.startedByRunId ?? null,
|
|
lastUsedAt: new Date().toISOString(),
|
|
startedAt: row.startedAt.toISOString(),
|
|
stoppedAt: null,
|
|
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
|
healthStatus: "healthy",
|
|
exposure: row.exposure ?? null,
|
|
reused: true,
|
|
db,
|
|
child: null,
|
|
leaseRunIds: new Set(),
|
|
idleTimer: null,
|
|
envFingerprint: row.reuseKey ?? "",
|
|
serviceKey: adoptedRecord.serviceKey,
|
|
profileKind: "workspace-runtime",
|
|
processGroupId: adoptedRecord.processGroupId ?? null,
|
|
exposureHandle: row.exposureHandle ?? null,
|
|
backendUrl: adoptedUrl,
|
|
exposureConfig: null,
|
|
};
|
|
registerRuntimeService(db, record);
|
|
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
|
|
runtimeServiceId: row.id,
|
|
lastSeenAt: record.lastUsedAt,
|
|
});
|
|
await persistRuntimeServiceRecord(db, record);
|
|
reconciled += 1;
|
|
adopted += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (row.status === "stopped") {
|
|
continue;
|
|
}
|
|
|
|
const now = new Date();
|
|
let stoppedExposure = row.exposure ?? null;
|
|
let stoppedExposureHandle = row.exposureHandle ?? null;
|
|
if (stoppedExposure) {
|
|
const cleanup = await deprovisionExposure(workspaceRuntimeExposureDeps, {
|
|
runtimeId: row.id,
|
|
handle: stoppedExposureHandle,
|
|
ports: stoppedExposure.listeners.map((listener) => listener.targetPort),
|
|
});
|
|
for (const port of cleanup.quarantinedPorts) quarantinedRuntimeExposurePorts.add(port);
|
|
stoppedExposure = {
|
|
...stoppedExposure,
|
|
state: cleanup.status.state,
|
|
publicUrl: null,
|
|
lastError: cleanup.status.lastError,
|
|
updatedAt: now.toISOString(),
|
|
};
|
|
if (cleanup.status.state === "removed") stoppedExposureHandle = null;
|
|
}
|
|
await db
|
|
.update(workspaceRuntimeServices)
|
|
.set({
|
|
status: "stopped",
|
|
healthStatus: "unknown",
|
|
// A row queued for HTTPS backfill drops its HTTP URL now rather than
|
|
// keeping it until the restart succeeds: if the restart fails, the
|
|
// fail-closed contract says show no URL, not a working HTTP one.
|
|
url: stoppedExposure || backfillDecision.action === "reprovision" ? null : row.url,
|
|
exposure: stoppedExposure,
|
|
exposureHandle: stoppedExposureHandle,
|
|
stoppedAt: now,
|
|
lastUsedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(workspaceRuntimeServices.id, row.id));
|
|
const registryRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
|
|
runtimeServiceId: row.id,
|
|
profileKind: "workspace-runtime",
|
|
});
|
|
if (registryRecord) {
|
|
await removeLocalServiceRegistryRecord(registryRecord.serviceKey);
|
|
}
|
|
reconciled += 1;
|
|
stopped += 1;
|
|
}
|
|
|
|
// Row reconciliation alone cannot repair the process-start crash window: the
|
|
// local service registry may contain a healthy managed process whose DB row
|
|
// was never committed. Re-applying persisted desired state adopts that
|
|
// registry entry (or restarts a missing service) and makes it visible to
|
|
// workspace cleanup through workspace_runtime_services again.
|
|
//
|
|
// It is also the second half of the HTTPS backfill: services stopped above as
|
|
// HTTP-only come back here through the ordinary start path, which now applies
|
|
// the `tailscale_https` default and only reports them healthy behind a
|
|
// verified HTTPS URL.
|
|
const desiredState = await restartDesiredRuntimeServicesOnStartup(db);
|
|
|
|
return {
|
|
reconciled,
|
|
adopted,
|
|
stopped,
|
|
backfilled,
|
|
restarted: desiredState.restarted,
|
|
restartFailed: desiredState.failed,
|
|
/** Stopped/removed rows whose reserved ports are live or mapped elsewhere. */
|
|
exposureReservationDrift,
|
|
};
|
|
}
|
|
|
|
export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
|
|
let restarted = 0;
|
|
let failed = 0;
|
|
|
|
const projectWorkspaceRows = await db
|
|
.select()
|
|
.from(projectWorkspaces);
|
|
const projectWorkspaceRowsById = new Map(projectWorkspaceRows.map((row) => [row.id, row] as const));
|
|
|
|
for (const row of projectWorkspaceRows) {
|
|
const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | null) ?? null);
|
|
if (runtimeConfig?.desiredState !== "running" || !runtimeConfig.workspaceRuntime || !row.cwd) continue;
|
|
|
|
try {
|
|
const refs = await startRuntimeServicesForWorkspaceControl({
|
|
db,
|
|
actor: { id: null, name: "Paperclip", companyId: row.companyId },
|
|
issue: null,
|
|
workspace: {
|
|
baseCwd: row.cwd,
|
|
source: "project_primary",
|
|
projectId: row.projectId,
|
|
workspaceId: row.id,
|
|
repoUrl: row.repoUrl ?? null,
|
|
repoRef: row.repoRef ?? null,
|
|
strategy: "project_primary",
|
|
cwd: row.cwd,
|
|
branchName: row.defaultRef ?? row.repoRef ?? null,
|
|
worktreePath: null,
|
|
warnings: [],
|
|
created: false,
|
|
branchCreatedByRuntime: false,
|
|
},
|
|
config: {
|
|
workspaceRuntime: runtimeConfig.workspaceRuntime,
|
|
desiredState: runtimeConfig.desiredState,
|
|
serviceStates: runtimeConfig.serviceStates ?? null,
|
|
},
|
|
adapterEnv: {},
|
|
respectDesiredStates: true,
|
|
});
|
|
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
|
|
} catch {
|
|
failed += 1;
|
|
}
|
|
}
|
|
|
|
const executionWorkspaceRows = await db
|
|
.select()
|
|
.from(executionWorkspaces)
|
|
.where(inArray(executionWorkspaces.status, ["active", "idle", "in_review", "cleanup_failed"]));
|
|
|
|
for (const row of executionWorkspaceRows) {
|
|
const config = readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null);
|
|
const inheritedRuntimeConfig = row.projectWorkspaceId
|
|
? readProjectWorkspaceRuntimeConfig(
|
|
(projectWorkspaceRowsById.get(row.projectWorkspaceId)?.metadata as Record<string, unknown> | null) ?? null,
|
|
)?.workspaceRuntime ?? null
|
|
: null;
|
|
const effectiveRuntimeConfig = config?.workspaceRuntime ?? inheritedRuntimeConfig;
|
|
if (config?.desiredState !== "running" || !effectiveRuntimeConfig || !row.cwd) continue;
|
|
|
|
try {
|
|
const refs = await startRuntimeServicesForWorkspaceControl({
|
|
db,
|
|
actor: { id: null, name: "Paperclip", companyId: row.companyId },
|
|
issue: row.sourceIssueId
|
|
? {
|
|
id: row.sourceIssueId,
|
|
identifier: null,
|
|
title: row.name,
|
|
}
|
|
: null,
|
|
workspace: {
|
|
baseCwd: row.cwd,
|
|
source: row.mode === "shared_workspace" ? "project_primary" : "task_session",
|
|
projectId: row.projectId,
|
|
workspaceId: row.projectWorkspaceId ?? null,
|
|
repoUrl: row.repoUrl ?? null,
|
|
repoRef: row.baseRef ?? null,
|
|
strategy: row.strategyType === "git_worktree" ? "git_worktree" : "project_primary",
|
|
cwd: row.cwd,
|
|
branchName: row.branchName ?? null,
|
|
worktreePath: row.strategyType === "git_worktree" ? row.cwd : null,
|
|
warnings: [],
|
|
created: false,
|
|
branchCreatedByRuntime: isRuntimeOwnedGitBranch(
|
|
row.metadata as Record<string, unknown> | null,
|
|
),
|
|
},
|
|
executionWorkspaceId: row.id,
|
|
config: {
|
|
workspaceRuntime: effectiveRuntimeConfig,
|
|
runtimeProvisionCommand: config.runtimeProvisionCommand,
|
|
desiredState: config.desiredState,
|
|
serviceStates: config.serviceStates ?? null,
|
|
},
|
|
adapterEnv: {},
|
|
respectDesiredStates: true,
|
|
});
|
|
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
|
|
} catch {
|
|
failed += 1;
|
|
}
|
|
}
|
|
|
|
return { restarted, failed };
|
|
}
|
|
|
|
export async function persistAdapterManagedRuntimeServices(input: {
|
|
db: Db;
|
|
adapterType: string;
|
|
runId: string;
|
|
agent: ExecutionWorkspaceAgentRef;
|
|
issue: ExecutionWorkspaceIssueRef | null;
|
|
workspace: RealizedExecutionWorkspace;
|
|
executionWorkspaceId?: string | null;
|
|
reports: AdapterRuntimeServiceReport[];
|
|
}) {
|
|
const refs = normalizeAdapterManagedRuntimeServices(input);
|
|
if (refs.length === 0) return refs;
|
|
|
|
const existingRows = await input.db
|
|
.select()
|
|
.from(workspaceRuntimeServices)
|
|
.where(inArray(workspaceRuntimeServices.id, refs.map((ref) => ref.id)));
|
|
const existingById = new Map(existingRows.map((row) => [row.id, row]));
|
|
|
|
for (const ref of refs) {
|
|
const existing = existingById.get(ref.id);
|
|
const startedAt = existing?.startedAt ?? new Date(ref.startedAt);
|
|
const createdAt = existing?.createdAt ?? new Date();
|
|
await input.db
|
|
.insert(workspaceRuntimeServices)
|
|
.values({
|
|
id: ref.id,
|
|
companyId: ref.companyId,
|
|
projectId: ref.projectId,
|
|
projectWorkspaceId: ref.projectWorkspaceId,
|
|
executionWorkspaceId: ref.executionWorkspaceId,
|
|
issueId: ref.issueId,
|
|
scopeType: ref.scopeType,
|
|
scopeId: ref.scopeId,
|
|
serviceName: ref.serviceName,
|
|
status: ref.status,
|
|
lifecycle: ref.lifecycle,
|
|
reuseKey: ref.reuseKey,
|
|
command: ref.command,
|
|
cwd: ref.cwd,
|
|
port: ref.port,
|
|
url: ref.url,
|
|
provider: ref.provider,
|
|
providerRef: ref.providerRef,
|
|
ownerAgentId: ref.ownerAgentId,
|
|
startedByRunId: ref.startedByRunId,
|
|
lastUsedAt: new Date(ref.lastUsedAt),
|
|
startedAt,
|
|
stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null,
|
|
stopPolicy: ref.stopPolicy,
|
|
exposure: null,
|
|
exposureHandle: null,
|
|
backendUrl: null,
|
|
healthStatus: ref.healthStatus,
|
|
createdAt,
|
|
updatedAt: new Date(),
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: workspaceRuntimeServices.id,
|
|
set: {
|
|
projectId: ref.projectId,
|
|
projectWorkspaceId: ref.projectWorkspaceId,
|
|
executionWorkspaceId: ref.executionWorkspaceId,
|
|
issueId: ref.issueId,
|
|
scopeType: ref.scopeType,
|
|
scopeId: ref.scopeId,
|
|
serviceName: ref.serviceName,
|
|
status: ref.status,
|
|
lifecycle: ref.lifecycle,
|
|
reuseKey: ref.reuseKey,
|
|
command: ref.command,
|
|
cwd: ref.cwd,
|
|
port: ref.port,
|
|
url: ref.url,
|
|
provider: ref.provider,
|
|
providerRef: ref.providerRef,
|
|
ownerAgentId: ref.ownerAgentId,
|
|
startedByRunId: ref.startedByRunId,
|
|
lastUsedAt: new Date(ref.lastUsedAt),
|
|
startedAt,
|
|
stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null,
|
|
stopPolicy: ref.stopPolicy,
|
|
exposure: null,
|
|
exposureHandle: null,
|
|
backendUrl: null,
|
|
healthStatus: ref.healthStatus,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
type WorkspaceReadyCommentInput = {
|
|
workspace: RealizedExecutionWorkspace;
|
|
runtimeServices: RuntimeServiceRef[];
|
|
};
|
|
|
|
const COMMENT_METADATA_LABEL_MAX_LENGTH = 120;
|
|
|
|
function workspaceReadyServiceLabel(serviceName: string): string {
|
|
const label = serviceName.trim() || "Service";
|
|
return label.length > COMMENT_METADATA_LABEL_MAX_LENGTH
|
|
? `${label.slice(0, COMMENT_METADATA_LABEL_MAX_LENGTH - 1)}…`
|
|
: label;
|
|
}
|
|
|
|
export function buildWorkspaceReadyPresentation(
|
|
input: WorkspaceReadyCommentInput,
|
|
): IssueCommentPresentation {
|
|
const workspaceLabel = input.workspace.branchName ?? input.workspace.strategy;
|
|
const title = `Workspace ready · ${workspaceLabel}`;
|
|
const hasWarnings = input.workspace.warnings.length > 0;
|
|
|
|
return {
|
|
kind: "system_notice",
|
|
tone: hasWarnings ? "warning" : "info",
|
|
title: title.length > 160 ? `${title.slice(0, 159)}…` : title,
|
|
density: "compact",
|
|
detailsDefaultOpen: hasWarnings,
|
|
};
|
|
}
|
|
|
|
export function buildWorkspaceReadyMetadata(
|
|
input: WorkspaceReadyCommentInput,
|
|
): IssueCommentMetadata {
|
|
const workspaceRows: IssueCommentMetadata["sections"][number]["rows"] = [
|
|
{ type: "key_value", label: "Strategy", value: input.workspace.strategy },
|
|
...(input.workspace.branchName
|
|
? [{ type: "key_value" as const, label: "Branch", value: input.workspace.branchName }]
|
|
: []),
|
|
{ type: "key_value", label: "CWD", value: input.workspace.cwd },
|
|
...(input.workspace.worktreePath && input.workspace.worktreePath !== input.workspace.cwd
|
|
? [{ type: "key_value" as const, label: "Worktree", value: input.workspace.worktreePath }]
|
|
: []),
|
|
];
|
|
const serviceRows: IssueCommentMetadata["sections"][number]["rows"] = input.runtimeServices.map(
|
|
(service) => ({
|
|
type: "key_value",
|
|
label: workspaceReadyServiceLabel(service.serviceName),
|
|
value: `${service.url ?? "running"}${service.reused ? " (reused)" : ""}`,
|
|
}),
|
|
);
|
|
|
|
return {
|
|
version: 1,
|
|
sections: [
|
|
{ title: "Workspace", rows: workspaceRows },
|
|
...(serviceRows.length > 0 ? [{ title: "Services", rows: serviceRows }] : []),
|
|
...(input.workspace.warnings.length > 0
|
|
? [{
|
|
title: "Warnings",
|
|
rows: input.workspace.warnings.map((warning) => ({ type: "text" as const, text: warning })),
|
|
}]
|
|
: []),
|
|
],
|
|
};
|
|
}
|
|
|
|
export function buildWorkspaceReadyComment(input: WorkspaceReadyCommentInput) {
|
|
const lines = ["## Workspace Ready", ""];
|
|
lines.push(`- Strategy: \`${input.workspace.strategy}\``);
|
|
if (input.workspace.branchName) lines.push(`- Branch: \`${input.workspace.branchName}\``);
|
|
lines.push(`- CWD: \`${input.workspace.cwd}\``);
|
|
if (input.workspace.worktreePath && input.workspace.worktreePath !== input.workspace.cwd) {
|
|
lines.push(`- Worktree: \`${input.workspace.worktreePath}\``);
|
|
}
|
|
for (const warning of input.workspace.warnings) {
|
|
lines.push(`- Warning: ${warning}`);
|
|
}
|
|
for (const service of input.runtimeServices) {
|
|
const detail = service.url ? `${service.serviceName}: ${service.url}` : `${service.serviceName}: running`;
|
|
const suffix = service.reused ? " (reused)" : "";
|
|
lines.push(`- Service: ${detail}${suffix}`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|