|
|
|
@@ -1,48 +1,140 @@
|
|
|
|
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
|
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
|
|
|
import { dirname, join } from "node:path";
|
|
|
|
|
import { WorkspaceFsAtV1, type OwnedWorkspaceFsAtRegularFile } from "./workspace-fs-at.js";
|
|
|
|
|
import { BorrowedVerifiedWorkspaceLockRootLease, VerifiedWorkspaceLockRootLease, type CanonicalWorkspaceId, type WorkspaceLockRootIdentityV1 } from "./workspace-lock-root-lease.js";
|
|
|
|
|
export interface ArtifactIdentity { readonly kind:string; readonly digest:string; readonly bytes:number; }
|
|
|
|
|
export interface PreprocessingRunStateV1 { readonly schemaVersion:1; readonly runId:string; readonly workspaceId:CanonicalWorkspaceId; readonly revision:string; readonly operation:string; readonly phase:string; readonly artifacts:readonly ArtifactIdentity[]; readonly createdAt:string; readonly updatedAt:string; }
|
|
|
|
|
export interface CreateRunInput { readonly runId?:string; readonly workspaceId:CanonicalWorkspaceId; readonly revision:string; readonly operation:string; readonly stateRoot:string; }
|
|
|
|
|
export interface ResumeRunInput { readonly runId:string; readonly workspaceId:CanonicalWorkspaceId; readonly revision:string; readonly operation:string; readonly stateRoot:string; }
|
|
|
|
|
export type RunTransition = { readonly phase:string; readonly [key:string]:unknown };
|
|
|
|
|
export interface FkReviewInput { readonly candidate:ArtifactIdentity; readonly reviewSha256:string; readonly annotationSha256?:string; }
|
|
|
|
|
export interface FkReviewRecordV1 extends FkReviewInput { readonly runId:string; readonly recordedAt:string; }
|
|
|
|
|
const digest=(x:Uint8Array|string)=>createHash("sha256").update(x).digest("hex");
|
|
|
|
|
function fail(msg="preprocessing_conflict"):Error{const e=new Error(msg);e.name="PreprocessingConflictError";return e;}
|
|
|
|
|
function id(v:string):void{if(!/^[0-9a-f]{32}$/.test(v))throw fail("invalid run id");}
|
|
|
|
|
import { mkdir, readFile, rename, rm, writeFile, open as openFile } from "node:fs/promises";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import {
|
|
|
|
|
WorkspaceFsAtV1,
|
|
|
|
|
type OwnedWorkspaceFsAtRegularFile,
|
|
|
|
|
} from "./workspace-fs-at.js";
|
|
|
|
|
import {
|
|
|
|
|
BorrowedVerifiedWorkspaceLockRootLease,
|
|
|
|
|
VerifiedWorkspaceLockRootLease,
|
|
|
|
|
type CanonicalWorkspaceId,
|
|
|
|
|
type WorkspaceLockRootIdentityV1,
|
|
|
|
|
} from "./workspace-lock-root-lease.js";
|
|
|
|
|
|
|
|
|
|
export interface ArtifactIdentity { readonly kind: string; readonly digest: string; readonly bytes: number; }
|
|
|
|
|
export interface PreprocessingRunStateV1 {
|
|
|
|
|
readonly schemaVersion: 1; readonly runId: string; readonly workspaceId: CanonicalWorkspaceId;
|
|
|
|
|
readonly revision: string; readonly operation: string; readonly phase: string;
|
|
|
|
|
readonly artifacts: readonly ArtifactIdentity[]; readonly createdAt: string; readonly updatedAt: string;
|
|
|
|
|
readonly [key: string]: unknown;
|
|
|
|
|
}
|
|
|
|
|
export interface CreateRunInput { readonly runId?: string; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly operation: string; readonly stateRoot?: string; }
|
|
|
|
|
export interface ResumeRunInput { readonly runId: string; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly operation: string; readonly stateRoot?: string; }
|
|
|
|
|
export type RunTransition = { readonly phase: string; readonly [key: string]: unknown };
|
|
|
|
|
export interface FkReviewInput { readonly candidate: ArtifactIdentity; readonly reviewSha256: string; readonly annotationSha256?: string; }
|
|
|
|
|
export interface FkReviewRecordV1 extends FkReviewInput { readonly runId: string; readonly recordedAt: string; }
|
|
|
|
|
|
|
|
|
|
const digest = (x: Uint8Array | string) => createHash("sha256").update(x).digest("hex");
|
|
|
|
|
const RUN_ID = /^[0-9a-f]{32}$/;
|
|
|
|
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
|
|
|
const REVISION = /^[0-9a-f]{40}$/;
|
|
|
|
|
const PHASES = ["created", "introspected", "fk_suggested", "fk_reviewed", "schema_indexed", "evidence_indexed", "lsh_built", "terminal"] as const;
|
|
|
|
|
function fail(msg = "preprocessing_conflict"): Error { const e = new Error(msg); e.name = "PreprocessingConflictError"; return e; }
|
|
|
|
|
function id(v: string): void { if (typeof v !== "string" || !RUN_ID.test(v)) throw fail("invalid run id"); }
|
|
|
|
|
function checkSha(v: string): void { if (typeof v !== "string" || !SHA256.test(v)) throw fail("invalid digest"); }
|
|
|
|
|
function checkedRoot(root: string | undefined): string { if (!root || typeof root !== "string" || !root.startsWith("/") || root.includes("\0")) throw fail(); return root; }
|
|
|
|
|
|
|
|
|
|
async function durableJson(path: string, value: unknown): Promise<void> {
|
|
|
|
|
const tmp = `${path}.tmp-${process.pid}-${randomBytes(5).toString("hex")}`;
|
|
|
|
|
try {
|
|
|
|
|
await writeFile(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: "wx" });
|
|
|
|
|
const handle = await openFile(tmp, "r"); try { await handle.sync(); } finally { await handle.close(); }
|
|
|
|
|
await rename(tmp, path);
|
|
|
|
|
const parent = await openFile(join(path, ".."), "r"); try { await parent.sync(); } finally { await parent.close(); }
|
|
|
|
|
} catch (error) { await rm(tmp, { force: true }).catch(() => undefined); throw error; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class PreprocessingStateStore {
|
|
|
|
|
private paths(input:{stateRoot:string;runId:string}){id(input.runId);return {dir:join(input.stateRoot,"preprocessing"),path:join(input.stateRoot,"preprocessing","jobs",`${input.runId}.json`),candidate:join(input.stateRoot,"preprocessing","fk-candidates",`${input.runId}.yaml`),review:join(input.stateRoot,"preprocessing","fk-reviews",`${input.runId}.json`)}}
|
|
|
|
|
async create(input:CreateRunInput):Promise<PreprocessingRunStateV1>{const runId=input.runId??Buffer.from(randomBytes(16)).toString("hex");id(runId);const p=this.paths({stateRoot:input.stateRoot,runId});await mkdir(join(p.dir,"jobs"),{recursive:true,mode:0o700});const now=new Date().toISOString();const state:PreprocessingRunStateV1={schemaVersion:1,runId,workspaceId:input.workspaceId,revision:input.revision,operation:input.operation,phase:"created",artifacts:[],createdAt:now,updatedAt:now};try{await writeFile(p.path,JSON.stringify(state)+"\n",{flag:"wx",mode:0o600});}catch{throw fail();}return state;}
|
|
|
|
|
async loadForResume(input:ResumeRunInput){const p=this.paths(input);let state:PreprocessingRunStateV1;try{state=JSON.parse(await readFile(p.path,"utf8"));}catch{throw fail();}if(state.schemaVersion!==1||state.runId!==input.runId||state.workspaceId!==input.workspaceId||state.revision!==input.revision||state.operation!==input.operation||!Array.isArray(state.artifacts))throw fail("preprocessing_resume_mismatch");return state;}
|
|
|
|
|
async transition(runId:string,transition:RunTransition):Promise<PreprocessingRunStateV1>{id(runId);throw new Error("transition requires a state-root bound store");}
|
|
|
|
|
async writeFkCandidate(runId:string,yaml:Uint8Array):Promise<ArtifactIdentity>{id(runId);throw new Error("writeFkCandidate requires a state-root bound store");}
|
|
|
|
|
async recordFkReview(runId:string,review:FkReviewInput):Promise<FkReviewRecordV1>{id(runId);throw new Error("recordFkReview requires a state-root bound store");}
|
|
|
|
|
constructor(private readonly configuredRoot?: string) {}
|
|
|
|
|
private root(input?: string): string { return checkedRoot(input ?? this.configuredRoot); }
|
|
|
|
|
private paths(input: { stateRoot?: string; runId: string }) {
|
|
|
|
|
id(input.runId); const root = this.root(input.stateRoot);
|
|
|
|
|
const base = join(root, "preprocessing");
|
|
|
|
|
return { base, jobs: join(base, "jobs"), path: join(base, "jobs", `${input.runId}.json`),
|
|
|
|
|
candidate: join(base, "fk-candidates", `${input.runId}.yaml`), review: join(base, "fk-reviews", `${input.runId}.json`) };
|
|
|
|
|
}
|
|
|
|
|
async create(input: CreateRunInput): Promise<PreprocessingRunStateV1> {
|
|
|
|
|
const runId = input.runId ?? randomBytes(16).toString("hex"); id(runId); const p = this.paths({ stateRoot: input.stateRoot, runId });
|
|
|
|
|
if (typeof input.workspaceId !== "string" || !/^[a-z][a-z0-9-]{2,62}$/.test(input.workspaceId) || !REVISION.test(input.revision) || !input.operation) throw fail();
|
|
|
|
|
await mkdir(p.jobs, { recursive: true, mode: 0o700 }); await mkdir(join(p.base, "fk-candidates"), { recursive: true, mode: 0o700 }); await mkdir(join(p.base, "fk-reviews"), { recursive: true, mode: 0o700 });
|
|
|
|
|
const now = new Date().toISOString(); const state: PreprocessingRunStateV1 = { schemaVersion: 1, runId, workspaceId: input.workspaceId, revision: input.revision, operation: input.operation, phase: "created", artifacts: [], createdAt: now, updatedAt: now };
|
|
|
|
|
try { await writeFile(p.path, `${JSON.stringify(state)}\n`, { flag: "wx", mode: 0o600 }); } catch { throw fail(); }
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
async loadForResume(input: ResumeRunInput): Promise<PreprocessingRunStateV1> {
|
|
|
|
|
const p = this.paths(input); let value: unknown;
|
|
|
|
|
try { value = JSON.parse(await readFile(p.path, "utf8")); } catch { throw fail(); }
|
|
|
|
|
if (!this.validState(value) || value.runId !== input.runId || value.workspaceId !== input.workspaceId || value.revision !== input.revision || value.operation !== input.operation) throw fail("preprocessing_resume_mismatch");
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
async load(input: ResumeRunInput): Promise<PreprocessingRunStateV1> { return this.loadForResume(input); }
|
|
|
|
|
async transition(runId: string, transition: RunTransition, stateRoot?: string): Promise<PreprocessingRunStateV1> {
|
|
|
|
|
const p = this.paths({ stateRoot, runId }); const current = await this.readRaw(p.path);
|
|
|
|
|
if (!this.validState(current) || current.runId !== runId || typeof transition !== "object" || !transition || typeof transition.phase !== "string") throw fail();
|
|
|
|
|
const old = PHASES.indexOf(current.phase as never); const next = PHASES.indexOf(transition.phase as never);
|
|
|
|
|
if (next < 0 || (old >= 0 && next < old) || current.phase === "terminal") throw fail();
|
|
|
|
|
const updated: PreprocessingRunStateV1 = { ...current, ...transition, schemaVersion: 1, runId, artifacts: current.artifacts, updatedAt: new Date().toISOString() };
|
|
|
|
|
await durableJson(p.path, updated); return updated;
|
|
|
|
|
}
|
|
|
|
|
async writeFkCandidate(runId: string, yaml: Uint8Array, stateRoot?: string): Promise<ArtifactIdentity> {
|
|
|
|
|
const p = this.paths({ stateRoot, runId }); if (!(yaml instanceof Uint8Array) || yaml.byteLength > 10 * 1024 * 1024) throw fail();
|
|
|
|
|
const artifact = { kind: "fk-candidate", digest: digest(yaml), bytes: yaml.byteLength } satisfies ArtifactIdentity;
|
|
|
|
|
await mkdir(join(p.base, "fk-candidates"), { recursive: true, mode: 0o700 }); await writeFile(p.candidate, yaml, { flag: "wx", mode: 0o600 });
|
|
|
|
|
return artifact;
|
|
|
|
|
}
|
|
|
|
|
async recordFkReview(runId: string, review: FkReviewInput, stateRoot?: string): Promise<FkReviewRecordV1> {
|
|
|
|
|
const p = this.paths({ stateRoot, runId }); if (!review || !review.candidate || !SHA256.test(review.candidate.digest)) throw fail(); checkSha(review.reviewSha256); if (review.annotationSha256) checkSha(review.annotationSha256);
|
|
|
|
|
const out: FkReviewRecordV1 = { ...review, runId, recordedAt: new Date().toISOString() }; await mkdir(join(p.base, "fk-reviews"), { recursive: true, mode: 0o700 }); await durableJson(p.review, out); return out;
|
|
|
|
|
}
|
|
|
|
|
private async readRaw(path: string): Promise<PreprocessingRunStateV1> { try { return JSON.parse(await readFile(path, "utf8")) as PreprocessingRunStateV1; } catch { throw fail(); } }
|
|
|
|
|
private validState(value: unknown): value is PreprocessingRunStateV1 {
|
|
|
|
|
if (!value || typeof value !== "object") return false; const x = value as Record<string, unknown>;
|
|
|
|
|
return x.schemaVersion === 1 && typeof x.runId === "string" && RUN_ID.test(x.runId) && typeof x.workspaceId === "string" && typeof x.revision === "string" && REVISION.test(x.revision) && typeof x.operation === "string" && typeof x.phase === "string" && Array.isArray(x.artifacts) && typeof x.createdAt === "string" && typeof x.updatedAt === "string";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DwhLockedChildRequest { readonly kind: "dwh_preprocess"; readonly stage: "introspect" | "lsh"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: unknown; readonly childRunId: string; }
|
|
|
|
|
export interface SchemaLockedChildRequest { readonly kind: "schema_preprocess"; readonly stage: "fk_suggest" | "fk_check" | "schema_index"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: unknown; readonly childRunId: string; readonly reviewedArtifact: ArtifactIdentity | null; }
|
|
|
|
|
export interface EvidenceLockedChildRequest { readonly kind: "evidence_preprocess"; readonly stage: "http_publish"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: unknown; readonly childRunId: string; }
|
|
|
|
|
export type WorkspaceLockedChildRequest = DwhLockedChildRequest | SchemaLockedChildRequest | EvidenceLockedChildRequest;
|
|
|
|
|
export interface WorkspaceLockedChildResult { readonly exitCode: number; readonly stdout: Uint8Array; readonly stderr: Uint8Array; }
|
|
|
|
|
|
|
|
|
|
export class BorrowedWorkspaceSessionReadersExclusiveLockLease {
|
|
|
|
|
private live = true; private constructor(readonly workspaceId: CanonicalWorkspaceId, readonly rootIdentity: WorkspaceLockRootIdentityV1) {}
|
|
|
|
|
static make(id: CanonicalWorkspaceId, root: WorkspaceLockRootIdentityV1) { return new BorrowedWorkspaceSessionReadersExclusiveLockLease(id, root); }
|
|
|
|
|
assertLive(): void { if (!this.live) throw fail(); }
|
|
|
|
|
invalidate(): void { this.live = false; }
|
|
|
|
|
}
|
|
|
|
|
export interface DwhLockedChildRequest { readonly kind:"dwh_preprocess"; readonly stage:"introspect"|"lsh"; readonly workspaceId:CanonicalWorkspaceId; readonly revision:string; readonly rootIdentity:WorkspaceLockRootIdentityV1; readonly runtimeConfig:unknown; readonly childRunId:string; }
|
|
|
|
|
export interface SchemaLockedChildRequest { readonly kind:"schema_preprocess"; readonly stage:"fk_suggest"|"fk_check"|"schema_index"; readonly workspaceId:CanonicalWorkspaceId; readonly revision:string; readonly rootIdentity:WorkspaceLockRootIdentityV1; readonly runtimeConfig:unknown; readonly childRunId:string; readonly reviewedArtifact:ArtifactIdentity|null; }
|
|
|
|
|
export interface EvidenceLockedChildRequest { readonly kind:"evidence_preprocess"; readonly stage:"http_publish"; readonly workspaceId:CanonicalWorkspaceId; readonly revision:string; readonly rootIdentity:WorkspaceLockRootIdentityV1; readonly runtimeConfig:unknown; readonly childRunId:string; }
|
|
|
|
|
export type WorkspaceLockedChildRequest=DwhLockedChildRequest|SchemaLockedChildRequest|EvidenceLockedChildRequest;
|
|
|
|
|
export interface WorkspaceLockedChildResult { readonly exitCode:number; readonly stdout:Uint8Array; readonly stderr:Uint8Array; }
|
|
|
|
|
export class BorrowedWorkspaceSessionReadersExclusiveLockLease { private live=true; private constructor(readonly workspaceId:CanonicalWorkspaceId,readonly rootIdentity:WorkspaceLockRootIdentityV1){} static make(id:CanonicalWorkspaceId,root:WorkspaceLockRootIdentityV1){return new BorrowedWorkspaceSessionReadersExclusiveLockLease(id,root);} assertLive(){if(!this.live)throw fail();} invalidate(){this.live=false;} }
|
|
|
|
|
export class WorkspaceWriterLockCapability {
|
|
|
|
|
private live=true; private constructor(readonly workspaceId:CanonicalWorkspaceId,readonly rootIdentity:WorkspaceLockRootIdentityV1,private readonly root:VerifiedWorkspaceLockRootLease,private readonly fs:WorkspaceFsAtV1,private readonly writer:OwnedWorkspaceFsAtRegularFile){}
|
|
|
|
|
static make(id:CanonicalWorkspaceId,identity:WorkspaceLockRootIdentityV1,root:VerifiedWorkspaceLockRootLease,fs:WorkspaceFsAtV1,writer:OwnedWorkspaceFsAtRegularFile){return new WorkspaceWriterLockCapability(id,identity,root,fs,writer);}
|
|
|
|
|
private check(){if(!this.live)throw fail();}
|
|
|
|
|
async runUnderSessionReadersExclusive<T>(action:(lease:BorrowedWorkspaceSessionReadersExclusiveLockLease)=>Promise<T>):Promise<T>{this.check();let lock:OwnedWorkspaceFsAtRegularFile|undefined;let borrowed:BorrowedWorkspaceSessionReadersExclusiveLockLease|undefined;try{const result=await this.root._withRoot(async(dir,fs)=>{lock=fs.openOrCreateLockAt(dir,"session-readers.lock",0o600);fs.flockOwnedLock(lock,"exclusive","nonblocking");borrowed=BorrowedWorkspaceSessionReadersExclusiveLockLease.make(this.workspaceId,this.rootIdentity);return action(borrowed);});return result;}catch(e){throw e;}finally{borrowed?.invalidate();try{lock?.close();}catch{this.live=false;throw fail();}}}
|
|
|
|
|
async spawnChild(_request:WorkspaceLockedChildRequest):Promise<WorkspaceLockedChildResult>{this.check();throw fail("child runner is not configured");}
|
|
|
|
|
async close():Promise<void>{if(!this.live)return;this.live=false;let e:unknown;try{this.writer.close();}catch(x){e=x;}try{await this.root.close();}catch(x){e??=x;}if(e)throw fail();}
|
|
|
|
|
private live = true; private readerExclusive = false;
|
|
|
|
|
private constructor(readonly workspaceId: CanonicalWorkspaceId, readonly rootIdentity: WorkspaceLockRootIdentityV1, private readonly root: VerifiedWorkspaceLockRootLease, private readonly fs: WorkspaceFsAtV1, private readonly writer: OwnedWorkspaceFsAtRegularFile) {}
|
|
|
|
|
static make(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, fs: WorkspaceFsAtV1, writer: OwnedWorkspaceFsAtRegularFile) { return new WorkspaceWriterLockCapability(id, identity, root, fs, writer); }
|
|
|
|
|
private check(): void { if (!this.live) throw fail(); }
|
|
|
|
|
async runUnderSessionReadersExclusive<T>(action: (lease: BorrowedWorkspaceSessionReadersExclusiveLockLease) => Promise<T>): Promise<T> {
|
|
|
|
|
this.check(); if (this.readerExclusive) throw fail(); this.readerExclusive = true;
|
|
|
|
|
let lock: OwnedWorkspaceFsAtRegularFile | undefined; let borrowed: BorrowedWorkspaceSessionReadersExclusiveLockLease | undefined; let result!: T;
|
|
|
|
|
try { result = await this.root._withRoot(async (dir, fs) => { lock = fs.openOrCreateLockAt(dir, "session-readers.lock", 0o600); fs.flockOwnedLock(lock, "exclusive", "nonblocking"); borrowed = BorrowedWorkspaceSessionReadersExclusiveLockLease.make(this.workspaceId, this.rootIdentity); return action(borrowed); }); return result; }
|
|
|
|
|
finally { borrowed?.invalidate(); let failed = false; try { lock?.close(); } catch { failed = true; } if (failed) { this.live = false; } this.readerExclusive = false; if (failed) throw fail(); }
|
|
|
|
|
}
|
|
|
|
|
async spawnChild(_request: WorkspaceLockedChildRequest): Promise<WorkspaceLockedChildResult> { this.check(); throw fail("child runner is not configured"); }
|
|
|
|
|
async close(): Promise<void> { if (!this.live) return; if (this.readerExclusive) throw fail(); this.live = false; let error: unknown; try { this.writer.close(); } catch (e) { error = e; } try { await this.root.close(); } catch (e) { error ??= e; } if (error) throw fail(); }
|
|
|
|
|
}
|
|
|
|
|
export interface OrderedWorkspaceCapability { readonly workspaceId: CanonicalWorkspaceId; readonly rootLease: BorrowedVerifiedWorkspaceLockRootLease; readonly writerCapability: WorkspaceWriterLockCapability; }
|
|
|
|
|
export class OrderedWorkspaceWriterCapabilitySet {
|
|
|
|
|
private live=true; private constructor(private readonly caps:Map<CanonicalWorkspaceId,WorkspaceWriterLockCapability>){ }
|
|
|
|
|
static make(caps:Map<CanonicalWorkspaceId,WorkspaceWriterLockCapability>){return new OrderedWorkspaceWriterCapabilitySet(caps);}
|
|
|
|
|
invalidate(){this.live=false;}
|
|
|
|
|
get workspaceIds(){if(!this.live)throw fail();return [...this.caps.keys()];}
|
|
|
|
|
async forWorkspace<T>(workspaceId:CanonicalWorkspaceId,action:(lease:{workspaceId:CanonicalWorkspaceId;rootLease:BorrowedVerifiedWorkspaceLockRootLease;writerCapability:WorkspaceWriterLockCapability})=>Promise<T>):Promise<T>{if(!this.live)throw fail();const cap=this.caps.get(workspaceId);if(!cap)throw fail();return cap["rootIdentity"]?action({workspaceId,rootLease:BorrowedVerifiedWorkspaceLockRootLease.make(cap.rootIdentity,()=>{if(!this.live)throw fail();}),writerCapability:cap}):Promise.reject(fail());}
|
|
|
|
|
async forEachWorkspace<T>(action:(lease:{workspaceId:CanonicalWorkspaceId;rootLease:BorrowedVerifiedWorkspaceLockRootLease;writerCapability:WorkspaceWriterLockCapability})=>Promise<T>){const out:T[]=[];for(const id of this.workspaceIds)out.push(await this.forWorkspace(id,action));return out;}
|
|
|
|
|
private live = true; private constructor(private readonly caps: Map<CanonicalWorkspaceId, WorkspaceWriterLockCapability>) {}
|
|
|
|
|
static make(caps: Map<CanonicalWorkspaceId, WorkspaceWriterLockCapability>) { return new OrderedWorkspaceWriterCapabilitySet(caps); }
|
|
|
|
|
invalidate(): void { this.live = false; }
|
|
|
|
|
get workspaceIds(): CanonicalWorkspaceId[] { if (!this.live) throw fail(); return [...this.caps.keys()]; }
|
|
|
|
|
async forWorkspace<T>(workspaceId: CanonicalWorkspaceId, action: (lease: OrderedWorkspaceCapability) => Promise<T>): Promise<T> { if (!this.live) throw fail(); const cap = this.caps.get(workspaceId); if (!cap) throw fail(); return action({ workspaceId, rootLease: BorrowedVerifiedWorkspaceLockRootLease.make(cap.rootIdentity, () => { if (!this.live) throw fail(); }), writerCapability: cap }); }
|
|
|
|
|
async forEachWorkspace<T>(action: (lease: OrderedWorkspaceCapability) => Promise<T>): Promise<T[]> { return Promise.all(this.workspaceIds.map(id => this.forWorkspace(id, action))); }
|
|
|
|
|
}
|
|
|
|
|
export async function runUnderOrderedWorkspaceWriterLocks<T>(rootLeases:readonly VerifiedWorkspaceLockRootLease[],action:(capabilities:OrderedWorkspaceWriterCapabilitySet)=>Promise<T>):Promise<T>{const sorted=[...rootLeases].sort((a,b)=>a.identity.workspaceId.localeCompare(b.identity.workspaceId));if(new Set(sorted.map(x=>x.identity.workspaceId)).size!==sorted.length)throw fail();const caps:WorkspaceWriterLockCapability[]=[];try{for(const source of sorted){const root=source.transfer();const fs=new WorkspaceFsAtV1();let cap:WorkspaceWriterLockCapability|undefined;await root._withRoot(async(dir)=>{const lock=fs.openOrCreateLockAt(dir,"writer.lock",0o600);fs.flockOwnedLock(lock,"exclusive","nonblocking");cap=WorkspaceWriterLockCapability.make(root.identity.workspaceId,root.identity,root,fs,lock);});if(!cap)throw fail();caps.push(cap);}const set=OrderedWorkspaceWriterCapabilitySet.make(new Map(caps.map(c=>[c.workspaceId,c])));try{return await action(set);}finally{set.invalidate();for(const c of caps.reverse())await c.close();}}catch(e){for(const c of caps.reverse())try{await c.close();}catch{}throw e;}}
|
|
|
|
|
export function runUnderWorkspaceWriterLock<T>(rootLease:VerifiedWorkspaceLockRootLease,action:(capability:WorkspaceWriterLockCapability)=>Promise<T>){return runUnderOrderedWorkspaceWriterLocks([rootLease],set=>set.forWorkspace(rootLease.identity.workspaceId,x=>action(x.writerCapability)));}
|
|
|
|
|
export async function probeWorkspaceWriterLock(rootLease:VerifiedWorkspaceLockRootLease):Promise<"available"|"held">{try{await runUnderWorkspaceWriterLock(rootLease,async()=>undefined);return "available";}catch{return "held";}}
|
|
|
|
|
export async function runUnderOrderedWorkspaceWriterLocks<T>(rootLeases: readonly VerifiedWorkspaceLockRootLease[], action: (capabilities: OrderedWorkspaceWriterCapabilitySet) => Promise<T>): Promise<T> {
|
|
|
|
|
const sorted = [...rootLeases].sort((a, b) => a.identity.workspaceId.localeCompare(b.identity.workspaceId)); if (new Set(sorted.map(x => x.identity.workspaceId)).size !== sorted.length) throw fail();
|
|
|
|
|
const caps: WorkspaceWriterLockCapability[] = [];
|
|
|
|
|
try { for (const source of sorted) { const root = source.transfer(); const fs = new WorkspaceFsAtV1(); let cap: WorkspaceWriterLockCapability | undefined; await root._withRoot(async (dir) => { const writer = fs.openOrCreateLockAt(dir, "writer.lock", 0o600); try { fs.flockOwnedLock(writer, "exclusive", "nonblocking"); cap = WorkspaceWriterLockCapability.make(root.identity.workspaceId, root.identity, root, fs, writer); } catch (e) { writer.close(); throw e; } }); if (!cap) throw fail(); caps.push(cap); }
|
|
|
|
|
const set = OrderedWorkspaceWriterCapabilitySet.make(new Map(caps.map(c => [c.workspaceId, c]))); try { return await action(set); } finally { set.invalidate(); for (const cap of [...caps].reverse()) await cap.close(); }
|
|
|
|
|
} catch (error) { for (const cap of [...caps].reverse()) await cap.close().catch(() => undefined); throw error; }
|
|
|
|
|
}
|
|
|
|
|
export function runUnderWorkspaceWriterLock<T>(rootLease: VerifiedWorkspaceLockRootLease, action: (capability: WorkspaceWriterLockCapability) => Promise<T>) { return runUnderOrderedWorkspaceWriterLocks([rootLease], set => set.forWorkspace(rootLease.identity.workspaceId, x => action(x.writerCapability))); }
|
|
|
|
|
export async function probeWorkspaceWriterLock(rootLease: VerifiedWorkspaceLockRootLease): Promise<"available" | "held"> { try { await runUnderWorkspaceWriterLock(rootLease, async () => undefined); return "available"; } catch { return "held"; } }
|
|
|
|
|