fix: harden workspace activation and snapshot retention
This commit is contained in:
@@ -143,3 +143,11 @@ export function resolveRuntimeBindings(
|
||||
embedding: resolveBinding(workspace, "EMBEDDING", env, secretRoots),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SSH bindings are currently probe-only: diagnostics owns a short-lived tunnel, while the
|
||||
* session runtime has no tunnel owner. Keep activation fail-closed until that lifecycle exists.
|
||||
*/
|
||||
export function supportsSessionRuntime(bindings: RuntimeBindings): boolean {
|
||||
return bindings.dwh.transport !== "ssh_tunnel" && bindings.vector.transport !== "ssh_tunnel";
|
||||
}
|
||||
|
||||
@@ -535,7 +535,9 @@ function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic {
|
||||
? "Installation binding is missing or invalid."
|
||||
: code === "semantic_index_incompatible"
|
||||
? "Semantic index metadata is incompatible with this workspace."
|
||||
: "Connector diagnostic failed.",
|
||||
: code === "workspace_not_activatable"
|
||||
? "This transport can be tested, but it is not available to runtime sessions."
|
||||
: "Connector diagnostic failed.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -851,6 +853,16 @@ export function createWorkspaceDiagnoser(
|
||||
}
|
||||
}
|
||||
|
||||
// The concrete SSH adapter deliberately owns only a bounded diagnostic tunnel and closes it
|
||||
// in `finally`. Until a session runtime owns an equivalent long-lived tunnel, a successful
|
||||
// probe is connectivity evidence only and must never be advertised as activatable.
|
||||
if (
|
||||
(bindings.dwh.transport === "ssh_tunnel" || bindings.vector.transport === "ssh_tunnel")
|
||||
&& !diagnostics.some((diagnostic) => diagnostic.level === "error")
|
||||
) {
|
||||
diagnostics.push(diagnosticError("workspace_not_activatable"));
|
||||
}
|
||||
|
||||
return {
|
||||
activatable: !diagnostics.some((diagnostic) => diagnostic.level === "error"),
|
||||
diagnostics,
|
||||
|
||||
@@ -28,6 +28,15 @@ export interface WorkspaceRevision {
|
||||
state: "operational" | "migration_required";
|
||||
}
|
||||
|
||||
export interface SessionRevisionLease {
|
||||
workspace: WorkspaceDescriptor;
|
||||
revision: WorkspaceRevision;
|
||||
/** Mark the manifest durable; retention removes the lease only after observing that manifest. */
|
||||
markPersisted(): Promise<void>;
|
||||
/** Remove a lease for a session that failed before its manifest was durable. */
|
||||
abort(): Promise<void>;
|
||||
}
|
||||
|
||||
export type PublishWorkspaceRequest =
|
||||
| { action: "create"; workspace: CanonicalWorkspace; baseCommit: string }
|
||||
| { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string }
|
||||
@@ -56,6 +65,14 @@ interface SnapshotManifest extends ActiveState {
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
interface RevisionLeaseRecord {
|
||||
version: 1;
|
||||
token: string;
|
||||
workspaceId: string;
|
||||
commit: string;
|
||||
state: "creating" | "persisted";
|
||||
}
|
||||
|
||||
type LegacyWorkspaceRevision = Omit<WorkspaceRevision, "state">;
|
||||
|
||||
interface LegacyActiveState {
|
||||
@@ -178,6 +195,56 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active revision and create its cross-process retention lease under the same
|
||||
* repository lock. The lease bridges the interval before `session_manifest.yaml` is durable.
|
||||
*/
|
||||
async acquireSessionRevision(id: string): Promise<SessionRevisionLease> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
const state = await this.activeState();
|
||||
const revision = state.revisions.find((candidate) => candidate.id === id);
|
||||
if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
let workspace: WorkspaceDescriptor;
|
||||
try {
|
||||
workspace = parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8"));
|
||||
} catch (error) {
|
||||
throw workspaceError(error);
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
const record: RevisionLeaseRecord = {
|
||||
version: 1,
|
||||
token,
|
||||
workspaceId: id,
|
||||
commit: revision.commit,
|
||||
state: "creating",
|
||||
};
|
||||
const path = await this.writeRevisionLease(record, true);
|
||||
let localState: RevisionLeaseRecord["state"] | "aborted" = "creating";
|
||||
|
||||
return {
|
||||
workspace,
|
||||
revision,
|
||||
markPersisted: async () => {
|
||||
if (localState === "persisted") return;
|
||||
if (localState === "aborted") throw new WorkspaceRegistryError(
|
||||
"workspace_invalid", "Workspace revision lease is unavailable",
|
||||
);
|
||||
await this.lock.run(async () => {
|
||||
await this.replaceRevisionLease(path, { ...record, state: "persisted" });
|
||||
});
|
||||
localState = "persisted";
|
||||
},
|
||||
abort: async () => {
|
||||
if (localState !== "creating") return;
|
||||
await this.lock.run(async () => { await rm(path, { force: true }); });
|
||||
localState = "aborted";
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a retained immutable snapshot for a session pinned to a historical commit. */
|
||||
async readPinned(id: string, commit: string): Promise<{ workspace: WorkspaceDescriptor; workspaceConfigPath: string }> {
|
||||
const snapshotPath = this.snapshotPath(safeCommit(commit), id);
|
||||
@@ -195,9 +262,12 @@ export class WorkspaceRegistry {
|
||||
* a partial, per-user list could otherwise remove another user's resumable workspace pin.
|
||||
*/
|
||||
async reconcileSnapshotRetention(referencedCommits: readonly string[]): Promise<void> {
|
||||
const retained = new Set(referencedCommits.map(safeCommit));
|
||||
const manifestReferences = new Set(referencedCommits.map(safeCommit));
|
||||
const retained = new Set(manifestReferences);
|
||||
await this.repository.ensureLayout();
|
||||
await this.lock.run(async () => {
|
||||
const leases = await this.revisionLeases();
|
||||
for (const { record } of leases) retained.add(record.commit);
|
||||
retained.add((await this.activeState()).head);
|
||||
const entries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -210,9 +280,77 @@ export class WorkspaceRegistry {
|
||||
if (!current.isDirectory() || current.isSymbolicLink()) continue;
|
||||
await rm(path, { recursive: true, force: true });
|
||||
}
|
||||
// A persisted lease is handed off only when this exact authoritative scan has observed a
|
||||
// manifest pin for its commit. A stale scan therefore keeps the lease and cannot prune it.
|
||||
for (const { path, record } of leases) {
|
||||
if (record.state === "persisted" && manifestReferences.has(record.commit)) {
|
||||
await rm(path, { force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private revisionLeaseDirectory(): string {
|
||||
return join(this.repository.statePath, "revision-leases");
|
||||
}
|
||||
|
||||
private async writeRevisionLease(record: RevisionLeaseRecord, exclusive: boolean): Promise<string> {
|
||||
const directory = this.revisionLeaseDirectory();
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const path = join(directory, `${record.token}.json`);
|
||||
await writeFile(path, JSON.stringify(record), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
flush: true,
|
||||
...(exclusive ? { flag: "wx" } : {}),
|
||||
});
|
||||
return path;
|
||||
}
|
||||
|
||||
private async replaceRevisionLease(path: string, record: RevisionLeaseRecord): Promise<void> {
|
||||
const staging = `${path}.staging-${randomUUID()}`;
|
||||
try {
|
||||
await writeFile(staging, JSON.stringify(record), {
|
||||
encoding: "utf8", mode: 0o600, flag: "wx", flush: true,
|
||||
});
|
||||
await rename(staging, path);
|
||||
} catch (error) {
|
||||
await rm(staging, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async revisionLeases(): Promise<Array<{ path: string; record: RevisionLeaseRecord }>> {
|
||||
const directory = this.revisionLeaseDirectory();
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const leases: Array<{ path: string; record: RevisionLeaseRecord }> = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.isSymbolicLink() || !/^[0-9a-f-]{36}\.json$/.test(entry.name)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
||||
}
|
||||
const path = join(directory, entry.name);
|
||||
let record: RevisionLeaseRecord;
|
||||
try {
|
||||
record = JSON.parse(await readFile(path, "utf8")) as RevisionLeaseRecord;
|
||||
} catch {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
||||
}
|
||||
if (
|
||||
record.version !== 1
|
||||
|| `${record.token}.json` !== entry.name
|
||||
|| !/^[0-9a-f-]{36}$/.test(record.token)
|
||||
|| !/^[a-z][a-z0-9-]{2,62}$/.test(record.workspaceId)
|
||||
|| !/^[0-9a-f]{40}$/.test(record.commit)
|
||||
|| (record.state !== "creating" && record.state !== "persisted")
|
||||
) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
||||
}
|
||||
leases.push({ path, record });
|
||||
}
|
||||
return leases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish canonical YAML and derived public documentation as one optimistic Git revision.
|
||||
* The browser never provides paths or generated artifacts; those are derived server-side.
|
||||
|
||||
Reference in New Issue
Block a user