mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 03:51:22 +02:00
985 lines
35 KiB
TypeScript
985 lines
35 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { ComputerRepository, ComputerBackend } from "./ports.js";
|
|
import {
|
|
ComputerError,
|
|
assertAdmission,
|
|
exactOwner,
|
|
expired,
|
|
liveOwners,
|
|
nextPort,
|
|
segment,
|
|
timeout,
|
|
type ComputerRecord,
|
|
type OwnerRef,
|
|
type Owner,
|
|
} from "../domain/ledger.js";
|
|
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
|
|
// A terminal run can enqueue its successor before remote teardown has finished.
|
|
// Keep the incumbent owner fenced until its own cleanup or warm handoff settles.
|
|
class ComputerSessionBusyError extends ComputerError {
|
|
constructor(readonly runId: string | undefined, readonly agentId: string | undefined) {
|
|
super("conflict", "Computer session already has an active turn");
|
|
}
|
|
}
|
|
|
|
type Scope = { companyId: string; environmentId: string };
|
|
export function createComputerService(
|
|
repository: ComputerRepository,
|
|
backend: ComputerBackend,
|
|
now: () => Date = () => new Date(),
|
|
options: {
|
|
admissionWaitMs?: number;
|
|
wait?: (ms: number) => Promise<void>;
|
|
} = {},
|
|
) {
|
|
const ref = (record: ComputerRecord, owner: Owner): OwnerRef => ({
|
|
computerId: record.id,
|
|
ownerId: owner.id,
|
|
generation: owner.generation,
|
|
});
|
|
const base = (record: ComputerRecord) =>
|
|
`/home/user/paperclip/${segment(record.companyId)}`;
|
|
async function admitRecord<T>(
|
|
scope: Scope,
|
|
change: (record: ComputerRecord) => T,
|
|
): Promise<T> {
|
|
const attempts = Math.ceil((options.admissionWaitMs ?? 120_000) / 1000);
|
|
const wait =
|
|
options.wait ??
|
|
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
for (let attempt = 0; ; attempt++) {
|
|
try {
|
|
return await repository.update(scope, (record) => {
|
|
assertAdmission(record.ledger);
|
|
return change(record);
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof ComputerError) ||
|
|
error.code !== "conflict" ||
|
|
attempt >= attempts
|
|
)
|
|
throw error;
|
|
const current = await repository.get(scope);
|
|
if (current.ledger.status !== "attached") throw error;
|
|
if (error instanceof ComputerSessionBusyError && error.runId && error.agentId) {
|
|
// A terminal database status does not prove the process has stopped.
|
|
// Wait for the existing owner transition; never retire it here or
|
|
// advance its generation while final commands might still run.
|
|
if (await repository.runState(scope, error.runId, error.agentId) !== "terminal") throw error;
|
|
await wait(1000);
|
|
continue;
|
|
}
|
|
if (!current.ledger.action) throw error;
|
|
await reconcileRecord(current);
|
|
if ((await repository.get(scope)).ledger.action) await wait(1000);
|
|
}
|
|
}
|
|
}
|
|
async function scoped(input: Scope & { owner: OwnerRef }) {
|
|
const record = await repository.get(input);
|
|
return { record, owner: exactOwner(record, input.owner) };
|
|
}
|
|
async function inspect(input: Scope) {
|
|
const record = await repository.get(input);
|
|
const status = await backend.inspect(record);
|
|
return {
|
|
computerId: record.id,
|
|
sandboxId: record.providerId,
|
|
status: record.ledger.status,
|
|
state: status.state,
|
|
};
|
|
}
|
|
async function attach(
|
|
input: Scope & {
|
|
sandboxId: string;
|
|
apiKeySecretRef: {
|
|
type: "secret_ref";
|
|
secretId: string;
|
|
version?: number | "latest";
|
|
};
|
|
},
|
|
) {
|
|
segment(input.companyId);
|
|
segment(input.environmentId);
|
|
segment(input.sandboxId);
|
|
segment(input.apiKeySecretRef.secretId);
|
|
let record: ComputerRecord;
|
|
try {
|
|
record = await repository.get(input);
|
|
if (record.providerId !== input.sandboxId)
|
|
throw new ComputerError(
|
|
"conflict",
|
|
"Environment already attaches another computer",
|
|
);
|
|
} catch (error) {
|
|
if (!(error instanceof ComputerError) || error.code !== "not_found")
|
|
throw error;
|
|
record = {
|
|
id: randomUUID(),
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
providerId: input.sandboxId,
|
|
ledger: {
|
|
controllerId: randomUUID(),
|
|
status: "attaching",
|
|
secretRef: input.apiKeySecretRef,
|
|
owners: [],
|
|
placements: {},
|
|
action: null,
|
|
},
|
|
};
|
|
await repository.create(record);
|
|
}
|
|
if (
|
|
JSON.stringify(record.ledger.secretRef) !==
|
|
JSON.stringify(input.apiKeySecretRef)
|
|
) {
|
|
const candidate = structuredClone(record);
|
|
candidate.ledger.secretRef = input.apiKeySecretRef;
|
|
const status = await backend.inspect(candidate);
|
|
if (!status.snapshots)
|
|
throw new ComputerError(
|
|
"invalid",
|
|
"Boat snapshots must remain enabled",
|
|
);
|
|
await repository.update(input, (current) => {
|
|
current.ledger.secretRef = input.apiKeySecretRef;
|
|
});
|
|
record = candidate;
|
|
}
|
|
if (record.ledger.status === "detaching" || record.ledger.action)
|
|
throw new ComputerError("conflict", "Computer cleanup is pending");
|
|
if (record.ledger.status !== "attached") {
|
|
await backend.ready(record);
|
|
await backend.claim(record);
|
|
await repository.update(input, (current) => {
|
|
if (
|
|
current.ledger.status === "attaching" ||
|
|
current.ledger.status === "detached"
|
|
)
|
|
current.ledger.status = "attached";
|
|
});
|
|
}
|
|
return { computerId: record.id };
|
|
}
|
|
async function binding(record: ComputerRecord, owner: Owner) {
|
|
const ownerRef = ref(record, owner);
|
|
const placement = record.ledger.placements[owner.sessionKey!];
|
|
if (!placement)
|
|
throw new ComputerError("conflict", "Computer placement is missing");
|
|
const raw = await backend.runner(record);
|
|
let pinnedProcess = owner.process ? structuredClone(owner.process) : null;
|
|
async function processScope() {
|
|
if (!pinnedProcess) return scoped({ ...record, owner: ownerRef });
|
|
const latest = await repository.get(record);
|
|
const candidate = latest.ledger.owners.find(
|
|
(value) => value.id === owner.id,
|
|
);
|
|
if (
|
|
!candidate?.process ||
|
|
!["active", "starting", "warm"].includes(candidate.phase) ||
|
|
candidate.process.nonce !== pinnedProcess.nonce ||
|
|
candidate.process.unitName !== pinnedProcess.unitName ||
|
|
candidate.process.bootId !== pinnedProcess.bootId ||
|
|
candidate.process.launchGeneration !== pinnedProcess.launchGeneration
|
|
) {
|
|
throw new ComputerError(
|
|
"conflict",
|
|
"Computer process capability has expired",
|
|
);
|
|
}
|
|
return { record: latest, owner: candidate };
|
|
}
|
|
const runnerFor = (
|
|
processScoped: boolean,
|
|
): CommandManagedRuntimeRunner => ({
|
|
execute: async (input) => {
|
|
const current = await (processScoped
|
|
? processScope()
|
|
: scoped({ ...record, owner: ownerRef }));
|
|
if (!["active", "starting", "warm"].includes(current.owner.phase))
|
|
throw new ComputerError("conflict", "Computer owner is retired");
|
|
// Start a command unit under the same owner slice while holding the remote tombstone lock.
|
|
// The lock is released only after systemd knows the unit, so retirement cannot miss a delayed launch.
|
|
return raw.execute({
|
|
command: "python3",
|
|
args: [
|
|
"-c",
|
|
String.raw`
|
|
import os,sys,json,fcntl,subprocess,time,tempfile,shutil
|
|
p=json.load(sys.stdin);os.umask(0o077);root='/home/user/.paperclip-owners/'+p['owner'];os.makedirs(root,exist_ok=True)
|
|
# A receipt reports foreground completion independently from cgroup lifetime.
|
|
# ExitType=cgroup keeps nohup/setsid descendants owned until owner retirement.
|
|
supervisor="""
|
|
import os,sys,json,subprocess
|
|
payload=sys.argv[1]
|
|
with open(payload) as f:p=json.load(f)
|
|
os.unlink(payload)
|
|
i=p['input']
|
|
try:
|
|
with open(p['stdin'],'rb') as source:
|
|
# Boat's persistent filesystem invalidates unlinked stdin descriptors.
|
|
# Keep the private backing file until the command's normal finally cleanup.
|
|
child=subprocess.Popen([i['command']]+i.get('args',[]),stdin=source)
|
|
code=child.wait()
|
|
except Exception as error:
|
|
print(str(error),file=sys.stderr);code=127
|
|
with open(p['receipt']+'.tmp','w') as f:json.dump({'exitCode':code},f)
|
|
os.replace(p['receipt']+'.tmp',p['receipt'])
|
|
"""
|
|
work=None
|
|
try:
|
|
with open(root+'/lock','a') as lock:
|
|
fcntl.flock(lock,fcntl.LOCK_EX)
|
|
if os.path.exists(root+'/retired'):raise RuntimeError('computer owner retired')
|
|
if p.get('processClaim'):
|
|
claim=json.load(open(root+'/claim.json'))
|
|
if any(claim[k]!=p['processClaim'][k] for k in ['nonce','unitName','bootId','launchGeneration']) or claim['bootId']!=open('/proc/sys/kernel/random/boot_id').read().strip():raise RuntimeError('computer process superseded')
|
|
elif not os.path.exists(root+'/generation') or int(open(root+'/generation').read())!=p['generation']:raise RuntimeError('computer owner superseded')
|
|
work=tempfile.mkdtemp(prefix='command-',dir=root)
|
|
output=work+'/stdout';error=work+'/stderr';receipt=work+'/receipt';payload=work+'/input.json';source=work+'/stdin'
|
|
for path in [output,error,source]:open(path,'wb').close()
|
|
i=p['input']
|
|
with open(source,'w') as f:f.write(i.get('stdin',''))
|
|
with open(payload,'w') as f:json.dump({'input':i,'stdin':source,'receipt':receipt},f)
|
|
args=['systemd-run','--user','--quiet','--collect','--unit='+p['unit'],'--slice=paperclip-'+p['owner']+'.slice','--property=Type=exec','--property=ExitType=cgroup','--property=KillMode=control-group','--property=StandardOutput=append:'+output,'--property=StandardError=append:'+error,'--working-directory='+i.get('cwd',p['cwd'])]
|
|
for k,v in i.get('env',{}).items():args.append('--setenv='+k+'='+v)
|
|
started=subprocess.run(args+['--','python3','-c',supervisor,payload],capture_output=True,text=True)
|
|
if started.returncode:sys.stderr.write(started.stderr);sys.exit(started.returncode)
|
|
with open(output,'rb') as out,open(error,'rb') as err:
|
|
while True:
|
|
done=os.path.exists(receipt)
|
|
for stream,dest in [(out,sys.stdout.buffer),(err,sys.stderr.buffer)]:
|
|
chunk=stream.read()
|
|
if chunk:dest.write(chunk);dest.flush()
|
|
if done:break
|
|
state=subprocess.run(['systemctl','--user','show',p['unit'],'--property=ActiveState','--value'],capture_output=True,text=True).stdout.strip()
|
|
if state not in ('active','activating','reloading'):
|
|
if os.path.exists(receipt):continue
|
|
raise RuntimeError('computer command stopped before completion')
|
|
time.sleep(.05)
|
|
with open(receipt) as f:code=json.load(f)['exitCode']
|
|
sys.exit(code if code>=0 else 128-code)
|
|
finally:
|
|
if work:shutil.rmtree(work,ignore_errors=True)
|
|
`,
|
|
],
|
|
stdin: JSON.stringify({
|
|
owner: owner.id,
|
|
generation: current.owner.generation,
|
|
processClaim: processScoped ? pinnedProcess : null,
|
|
unit: `paperclip-command-${randomUUID()}.service`,
|
|
cwd: placement.cwd,
|
|
input: {
|
|
command: input.command,
|
|
args: input.args,
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
stdin: input.stdin,
|
|
},
|
|
}),
|
|
timeoutMs: input.timeoutMs,
|
|
onLog: input.onLog,
|
|
onSpawn: input.onSpawn,
|
|
});
|
|
},
|
|
});
|
|
return {
|
|
owner: ownerRef,
|
|
runner: runnerFor(false),
|
|
computerTool: backend.computerTool(),
|
|
process: {
|
|
runner: runnerFor(true),
|
|
async ingress(input?: { port?: number; path?: string }) {
|
|
const current = await processScope();
|
|
return backend.ingress(
|
|
current.record,
|
|
input?.port ?? current.owner.port,
|
|
input?.path ?? "/",
|
|
);
|
|
},
|
|
},
|
|
listenerPort: owner.port,
|
|
remoteCwd: placement.cwd,
|
|
agentHome: placement.root,
|
|
placementId: placement.id,
|
|
async launch(input: {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
}) {
|
|
const before = await scoped({ ...record, owner: ownerRef });
|
|
const existing = before.owner.process
|
|
? await backend.inspectProcess(before.record, before.owner)
|
|
: null;
|
|
const claimed = await repository.update(record, (current) => {
|
|
const o = exactOwner(current, ownerRef);
|
|
if (o.phase !== "active" && o.phase !== "starting")
|
|
throw new ComputerError("conflict", "Computer owner cannot launch");
|
|
if (existing?.running && existing.claim) o.process = existing.claim;
|
|
else if (!o.process || !existing?.running)
|
|
o.process = {
|
|
bootId: "",
|
|
unitName: `paperclip-${o.id}.service`,
|
|
nonce: randomUUID(),
|
|
launchGeneration: (o.process?.launchGeneration ?? 0) + 1,
|
|
};
|
|
return structuredClone(current);
|
|
});
|
|
const claim = await backend.launch(
|
|
claimed,
|
|
exactOwner(claimed, ownerRef),
|
|
input,
|
|
);
|
|
await repository.update(record, (current) => {
|
|
exactOwner(current, ownerRef).process = claim;
|
|
});
|
|
pinnedProcess = structuredClone(claim);
|
|
return claim;
|
|
},
|
|
async inspectProcess() {
|
|
const current = await scoped({ ...record, owner: ownerRef });
|
|
return backend.inspectProcess(current.record, current.owner);
|
|
},
|
|
async ingress(input?: { port?: number; path?: string }) {
|
|
const current = await scoped({ ...record, owner: ownerRef });
|
|
if (current.owner.phase === "retired")
|
|
throw new ComputerError("conflict", "Computer owner retired");
|
|
return backend.ingress(
|
|
current.record,
|
|
input?.port ?? owner.port,
|
|
input?.path ?? "/",
|
|
);
|
|
},
|
|
};
|
|
}
|
|
async function admit(
|
|
input: Scope & {
|
|
agentId: string;
|
|
runId?: string;
|
|
probeId?: string;
|
|
sessionKey: string;
|
|
idleTimeoutMs: number;
|
|
},
|
|
) {
|
|
segment(input.agentId);
|
|
if ((!input.runId && !input.probeId) || (input.runId && input.probeId))
|
|
throw new ComputerError(
|
|
"invalid",
|
|
"Computer admission requires one run or probe identity",
|
|
);
|
|
if (input.probeId) segment(input.probeId);
|
|
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(input.sessionKey))
|
|
throw new ComputerError("invalid", "Invalid computer session key");
|
|
timeout(input.idleTimeoutMs);
|
|
const result = await admitRecord(input, (record) => {
|
|
let owner = record.ledger.owners.find(
|
|
(o) =>
|
|
o.kind === "runner" &&
|
|
o.agentId === input.agentId &&
|
|
o.sessionKey === input.sessionKey &&
|
|
o.phase !== "retired",
|
|
);
|
|
if (owner && owner.phase !== "warm")
|
|
throw new ComputerSessionBusyError(owner.runId, owner.agentId);
|
|
if (owner && expired(owner, now()))
|
|
throw new ComputerError(
|
|
"conflict",
|
|
"Computer warm session is retiring",
|
|
);
|
|
if (owner) {
|
|
owner.generation++;
|
|
owner.phase = "starting";
|
|
owner.runId = input.runId;
|
|
owner.admittedAt = now().toISOString();
|
|
owner.deadline = new Date(now().getTime() + 120_000).toISOString();
|
|
} else {
|
|
owner = {
|
|
id: randomUUID(),
|
|
generation: 1,
|
|
kind: "runner",
|
|
phase: "starting",
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
probeId: input.probeId,
|
|
admittedAt: now().toISOString(),
|
|
sessionKey: input.sessionKey,
|
|
port: nextPort(record.ledger.owners),
|
|
deadline: new Date(now().getTime() + 120_000).toISOString(),
|
|
absoluteDeadline: null,
|
|
process: null,
|
|
};
|
|
record.ledger.owners.push(owner);
|
|
}
|
|
record.ledger.placements[input.agentId] ??= {
|
|
id: randomUUID(),
|
|
root: `${base(record)}/agents/${input.agentId}`,
|
|
cwd: `${base(record)}/agents/${input.agentId}`,
|
|
};
|
|
record.ledger.placements[input.sessionKey] ??= {
|
|
id: randomUUID(),
|
|
root: `${base(record)}/agents/${input.agentId}`,
|
|
cwd: input.probeId
|
|
? `${base(record)}/probes/${input.probeId}`
|
|
: `${base(record)}/agents/${input.agentId}`,
|
|
};
|
|
return { record: structuredClone(record), owner: structuredClone(owner) };
|
|
});
|
|
await backend.ready(result.record);
|
|
await backend.claim(result.record);
|
|
await backend.advance(result.record, result.owner);
|
|
await backend.renew(result.record);
|
|
if (input.probeId) {
|
|
// Readiness commands run before managed-home preparation. Give them an
|
|
// existing workspace without creating the personal home: an absent home
|
|
// must remain eligible for the first atomic managed-file seed.
|
|
await backend.remote(result.record, {
|
|
action: "seed",
|
|
root: result.record.ledger.placements[input.sessionKey]!.cwd,
|
|
files: {},
|
|
});
|
|
}
|
|
const active = await repository.update(input, (record) => {
|
|
const owner = exactOwner(record, ref(result.record, result.owner));
|
|
if (owner.phase !== "starting")
|
|
throw new ComputerError("conflict", "Computer admission was retired");
|
|
owner.phase = "active";
|
|
owner.deadline = owner.probeId
|
|
? new Date(
|
|
now().getTime() + Math.min(input.idleTimeoutMs, 120_000),
|
|
).toISOString()
|
|
: null;
|
|
return { record: structuredClone(record), owner: structuredClone(owner) };
|
|
});
|
|
return binding(active.record, active.owner);
|
|
}
|
|
async function recover(input: Scope & { owner: OwnerRef }) {
|
|
const { record, owner } = await scoped(input);
|
|
if (!["active", "warm"].includes(owner.phase) || expired(owner, now()))
|
|
throw new ComputerError(
|
|
"conflict",
|
|
"Computer owner is no longer recoverable",
|
|
);
|
|
await backend.ready(record);
|
|
await backend.claim(record);
|
|
return binding(record, owner);
|
|
}
|
|
async function retainWarm(
|
|
input: Scope & { owner: OwnerRef; idleTimeoutMs: number },
|
|
) {
|
|
timeout(input.idleTimeoutMs);
|
|
await repository.update(input, (record) => {
|
|
const owner = exactOwner(record, input.owner);
|
|
if (owner.kind !== "runner" || owner.probeId || owner.phase !== "active")
|
|
throw new ComputerError(
|
|
"conflict",
|
|
"Computer owner cannot become warm",
|
|
);
|
|
owner.phase = "warm";
|
|
owner.deadline = new Date(
|
|
now().getTime() + input.idleTimeoutMs,
|
|
).toISOString();
|
|
});
|
|
}
|
|
async function isRetired(input: Scope & { owner: OwnerRef; agentId: string; runId: string }) {
|
|
const record = await repository.get(input);
|
|
if (record.id !== input.owner.computerId) return false;
|
|
return record.ledger.owners.some((owner) =>
|
|
owner.id === input.owner.ownerId &&
|
|
owner.generation === input.owner.generation &&
|
|
owner.kind === "runner" &&
|
|
owner.phase === "retired" &&
|
|
owner.agentId === input.agentId &&
|
|
owner.runId === input.runId,
|
|
);
|
|
}
|
|
async function retire(input: Scope & { owner: OwnerRef }) {
|
|
const state = await repository.update(input, (record) => {
|
|
const owner = record.ledger.owners.find(
|
|
(value) => value.id === input.owner.ownerId,
|
|
);
|
|
if (record.id !== input.owner.computerId || !owner)
|
|
throw new ComputerError("not_found", "Computer owner not found");
|
|
if (owner.generation !== input.owner.generation) return null;
|
|
if (owner.phase !== "retired") owner.phase = "retiring";
|
|
return { record: structuredClone(record), owner: structuredClone(owner) };
|
|
});
|
|
if (!state) return { retired: false };
|
|
if (state.owner.phase === "retired") return { retired: true };
|
|
if (state.owner.kind === "runner") {
|
|
const status = await backend.inspect(state.record);
|
|
if (!["stopped", "archived"].includes(status.state))
|
|
await backend.retire(state.record, state.owner);
|
|
}
|
|
await repository.update(input, (record) => {
|
|
const owner = exactOwner(record, input.owner);
|
|
owner.phase = "retired";
|
|
owner.deadline = null;
|
|
});
|
|
return { retired: true };
|
|
}
|
|
async function connect(
|
|
input: Scope & { userId: string; idleTimeoutMs: number },
|
|
) {
|
|
timeout(input.idleTimeoutMs);
|
|
const deadline = new Date(
|
|
now().getTime() + input.idleTimeoutMs,
|
|
).toISOString();
|
|
const data = await admitRecord(input, (record) => {
|
|
const owner: Owner = {
|
|
id: randomUUID(),
|
|
generation: 1,
|
|
kind: "viewer",
|
|
phase: "active",
|
|
userId: input.userId,
|
|
port: 0,
|
|
deadline,
|
|
absoluteDeadline: deadline,
|
|
process: null,
|
|
};
|
|
record.ledger.owners.push(owner);
|
|
return { record: structuredClone(record), owner };
|
|
});
|
|
await backend.ready(data.record);
|
|
await backend.claim(data.record);
|
|
await backend.renew(data.record);
|
|
const viewer = await backend.desktop(data.record);
|
|
return {
|
|
...viewer,
|
|
expiresAt: new Date(
|
|
Math.min(Date.parse(viewer.expiresAt), Date.parse(deadline)),
|
|
).toISOString(),
|
|
owner: ref(data.record, data.owner),
|
|
};
|
|
}
|
|
async function renewViewer(
|
|
input: Scope & { owner: OwnerRef; userId: string },
|
|
) {
|
|
const { record, owner } = await scoped(input);
|
|
if (owner.kind !== "viewer" || owner.userId !== input.userId)
|
|
throw new ComputerError(
|
|
"forbidden",
|
|
"Computer viewer belongs to another user",
|
|
);
|
|
if (owner.phase !== "active" || expired(owner, now()))
|
|
throw new ComputerError(
|
|
"conflict",
|
|
"Computer connection expired; connect again",
|
|
);
|
|
const viewer = await backend.desktop(record);
|
|
return {
|
|
...viewer,
|
|
expiresAt: new Date(
|
|
Math.min(
|
|
Date.parse(viewer.expiresAt),
|
|
Date.parse(owner.absoluteDeadline!),
|
|
),
|
|
).toISOString(),
|
|
owner: input.owner,
|
|
};
|
|
}
|
|
async function disconnectViewer(
|
|
input: Scope & { owner: OwnerRef; userId: string },
|
|
) {
|
|
const { owner } = await scoped(input);
|
|
if (owner.kind !== "viewer" || owner.userId !== input.userId)
|
|
throw new ComputerError(
|
|
"forbidden",
|
|
"Computer viewer belongs to another user",
|
|
);
|
|
await retire(input);
|
|
}
|
|
async function preview(input: Scope & { owner: OwnerRef; port: number }) {
|
|
const { record, owner } = await scoped(input);
|
|
if (
|
|
owner.kind !== "runner" ||
|
|
!["active", "warm"].includes(owner.phase) ||
|
|
expired(owner, now())
|
|
)
|
|
throw new ComputerError("conflict", "Preview owner is not live");
|
|
if (input.port >= 43127 && input.port < 44127)
|
|
throw new ComputerError(
|
|
"forbidden",
|
|
"Runner ports are not application previews",
|
|
);
|
|
try {
|
|
await backend.remote(record, {
|
|
root: base(record),
|
|
action: "owned-port",
|
|
port: input.port,
|
|
ownerId: owner.id,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof ComputerError && error.code === "conflict") {
|
|
throw new ComputerError("conflict", "No dev server is running on this port for this task. Start it in the task and try again.");
|
|
}
|
|
throw error;
|
|
}
|
|
return backend.preview(record, input.port);
|
|
}
|
|
async function withFiles<T>(
|
|
input: Scope,
|
|
root: string,
|
|
fn: (record: ComputerRecord) => Promise<T>,
|
|
): Promise<T> {
|
|
const data = await admitRecord(input, (record) => {
|
|
const owner: Owner = {
|
|
id: randomUUID(),
|
|
generation: 1,
|
|
kind: "file-operation",
|
|
phase: "active",
|
|
port: 0,
|
|
deadline: new Date(now().getTime() + 120_000).toISOString(),
|
|
absoluteDeadline: null,
|
|
process: null,
|
|
};
|
|
record.ledger.owners.push(owner);
|
|
return { record: structuredClone(record), owner };
|
|
});
|
|
try {
|
|
await backend.ready(data.record);
|
|
await backend.claim(data.record);
|
|
return await fn(data.record);
|
|
} finally {
|
|
await repository.update(input, (record) => {
|
|
const owner = record.ledger.owners.find(
|
|
(value) => value.id === data.owner.id,
|
|
);
|
|
if (owner?.phase === "active") {
|
|
owner.phase = "warm";
|
|
owner.deadline = new Date(now().getTime() + 5000).toISOString();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
function fileAccess(input: Scope, root: string) {
|
|
const remote = (payload: Record<string, unknown>) =>
|
|
withFiles(input, root, (record) =>
|
|
backend.remote(record, { ...payload, root }),
|
|
);
|
|
const readBytes = async (
|
|
path: string,
|
|
maxBytes = 16 * 1024 * 1024,
|
|
): Promise<{ bytes: Buffer; sha256: string }> => {
|
|
if (
|
|
!Number.isSafeInteger(maxBytes) ||
|
|
maxBytes < 0 ||
|
|
maxBytes > 16 * 1024 * 1024
|
|
)
|
|
throw new ComputerError("invalid", "Invalid remote file size limit");
|
|
const value = await remote({ action: "read", path, maxBytes });
|
|
return {
|
|
bytes: Buffer.from(value.base64, "base64"),
|
|
sha256: value.sha256,
|
|
};
|
|
};
|
|
const writeBytes = async (
|
|
path: string,
|
|
bytes: Buffer,
|
|
expectedSha256: string | null,
|
|
): Promise<{ sha256: string }> =>
|
|
remote({
|
|
action: "write",
|
|
path,
|
|
base64: bytes.toString("base64"),
|
|
expectedSha256,
|
|
});
|
|
return {
|
|
root,
|
|
readBytes,
|
|
writeBytes,
|
|
async read(path: string) {
|
|
const value = await readBytes(path);
|
|
return { content: value.bytes.toString("utf8"), sha256: value.sha256 };
|
|
},
|
|
async write(
|
|
path: string,
|
|
content: string,
|
|
expectedSha256: string | null,
|
|
) {
|
|
return writeBytes(path, Buffer.from(content), expectedSha256);
|
|
},
|
|
async listPage(path = "", options: { limit?: number } = {}): Promise<{
|
|
entries: Array<{ name: string; kind: "file" | "directory"; size: number; mtimeMs: number }>;
|
|
truncated: boolean;
|
|
}> {
|
|
return remote({ action: "list", path, limit: options.limit ?? 1000 });
|
|
},
|
|
async list(path = "") {
|
|
const page = await this.listPage(path);
|
|
if (page.truncated) throw new ComputerError("invalid", "Computer directory exceeds the listing limit");
|
|
return page.entries;
|
|
},
|
|
async stat(path: string): Promise<{ name: string; kind: "file" | "directory"; size: number; mtimeMs: number }> {
|
|
return remote({ action: "stat", path });
|
|
},
|
|
async hash(path: string): Promise<{ sha256: string; size: number }> {
|
|
return remote({ action: "hash", path });
|
|
},
|
|
async remove(path: string, expectedSha256: string) {
|
|
await remote({ action: "remove", path, expectedSha256 });
|
|
},
|
|
async move(
|
|
from: string,
|
|
to: string,
|
|
expectedSha256: string,
|
|
): Promise<{ sha256: string }> {
|
|
return remote({ action: "move", path: from, to, expectedSha256 });
|
|
},
|
|
async seedBytes(
|
|
files: Record<string, Buffer>,
|
|
): Promise<{ seeded: boolean }> {
|
|
return remote({
|
|
action: "seed",
|
|
files: Object.fromEntries(
|
|
Object.entries(files).map(([key, value]) => [
|
|
key,
|
|
value.toString("base64"),
|
|
]),
|
|
),
|
|
});
|
|
},
|
|
async seed(files: Record<string, string>): Promise<{ seeded: boolean }> {
|
|
return remote({
|
|
action: "seed",
|
|
files: Object.fromEntries(
|
|
Object.entries(files).map(([key, value]) => [
|
|
key,
|
|
Buffer.from(value).toString("base64"),
|
|
]),
|
|
),
|
|
});
|
|
},
|
|
};
|
|
}
|
|
async function files(input: Scope & { agentId: string }) {
|
|
segment(input.agentId);
|
|
const placement = await admitRecord(input, (record) => {
|
|
return (record.ledger.placements[input.agentId] ??= {
|
|
id: randomUUID(),
|
|
root: `${base(record)}/agents/${input.agentId}`,
|
|
cwd: `${base(record)}/agents/${input.agentId}`,
|
|
});
|
|
});
|
|
return fileAccess(input, placement.root);
|
|
}
|
|
async function workspaceFiles(input: Scope & { placementId: string }) {
|
|
const record = await repository.get(input);
|
|
const placement = Object.values(record.ledger.placements).find(
|
|
(p) => p.id === input.placementId,
|
|
);
|
|
if (!placement)
|
|
throw new ComputerError(
|
|
"not_found",
|
|
"Computer workspace placement not found",
|
|
);
|
|
return fileAccess(input, placement.cwd);
|
|
}
|
|
async function realizeWorkspace(
|
|
input: Scope & {
|
|
owner: OwnerRef;
|
|
projectId?: string;
|
|
taskId?: string;
|
|
repositoryUrl?: string;
|
|
/** Transient Git helper configuration; sent over stdin, never retained in the ledger. */
|
|
gitAuth?: { configArgs: string[]; env: Record<string, string> };
|
|
branch?: string;
|
|
baseRef?: string;
|
|
mode: "shared" | "worktree";
|
|
},
|
|
) {
|
|
const { record, owner } = await scoped(input);
|
|
if (!owner.agentId || owner.phase !== "active")
|
|
throw new ComputerError("conflict", "Workspace owner is not active");
|
|
const placement = record.ledger.placements[owner.sessionKey!]!;
|
|
if (!input.projectId)
|
|
return {
|
|
remoteCwd: placement.cwd,
|
|
agentHome: placement.root,
|
|
placementId: placement.id,
|
|
};
|
|
const root = `${base(record)}/projects/${segment(input.projectId)}`;
|
|
if (input.taskId) segment(input.taskId);
|
|
const result = await backend.remote(record, {
|
|
...input,
|
|
root,
|
|
action: "workspace",
|
|
});
|
|
await repository.update(input, (current) => {
|
|
exactOwner(current, input.owner);
|
|
const placementKey = `workspace:${owner.agentId}:${input.projectId}:${input.mode}:${input.taskId ?? "shared"}:${result.remoteCwd}`;
|
|
current.ledger.placements[placementKey] ??= {
|
|
id: randomUUID(),
|
|
root: placement.root,
|
|
cwd: result.remoteCwd,
|
|
};
|
|
current.ledger.placements[owner.sessionKey!] =
|
|
current.ledger.placements[placementKey]!;
|
|
});
|
|
const updated = await repository.get(input);
|
|
return {
|
|
remoteCwd: result.remoteCwd as string,
|
|
agentHome: placement.root,
|
|
placementId: updated.ledger.placements[owner.sessionKey!]!.id,
|
|
};
|
|
}
|
|
async function detach(input: Scope) {
|
|
const record = await repository.update(input, (current) => {
|
|
current.ledger.status = "detaching";
|
|
return structuredClone(current);
|
|
});
|
|
for (const owner of liveOwners(record.ledger))
|
|
await retire({ ...input, owner: ref(record, owner) });
|
|
await reconcileRecord(await repository.get(input));
|
|
}
|
|
const reconciliation = new Map<string, Promise<void>>();
|
|
async function reconcileRecord(record: ComputerRecord): Promise<void> {
|
|
const existing = reconciliation.get(record.id);
|
|
if (existing) return existing;
|
|
const pending = (async () =>
|
|
reconcileOwnedRecord(await repository.get(record)))();
|
|
reconciliation.set(record.id, pending);
|
|
try {
|
|
await pending;
|
|
} finally {
|
|
if (reconciliation.get(record.id) === pending)
|
|
reconciliation.delete(record.id);
|
|
}
|
|
}
|
|
async function reconcileOwnedRecord(record: ComputerRecord) {
|
|
if (record.ledger.status === "attaching") return;
|
|
// A crash can happen after admission but before an environment lease exists.
|
|
// Run identity, rather than lease presence, decides whether that active owner
|
|
// still has work to recover. Warm owners keep their own idle deadline.
|
|
for (const owner of liveOwners(record.ledger)) {
|
|
const admittedAt = owner.admittedAt ? Date.parse(owner.admittedAt) : 0;
|
|
if (
|
|
owner.kind === "runner" &&
|
|
owner.phase === "active" &&
|
|
owner.runId &&
|
|
owner.agentId &&
|
|
now().getTime() - admittedAt >= 120_000
|
|
) {
|
|
const runState = await repository.runState(
|
|
record,
|
|
owner.runId,
|
|
owner.agentId,
|
|
);
|
|
if (runState !== "active")
|
|
await retire({ ...record, owner: ref(record, owner) });
|
|
}
|
|
}
|
|
record = await repository.get(record);
|
|
for (const owner of liveOwners(record.ledger))
|
|
if (owner.phase === "retiring" || expired(owner, now()))
|
|
await retire({ ...record, owner: ref(record, owner) });
|
|
await repository.update(record, (current) => {
|
|
current.ledger.owners = current.ledger.owners.filter(
|
|
(owner) => owner.kind !== "file-operation" || owner.phase !== "retired",
|
|
);
|
|
});
|
|
record = await repository.get(record);
|
|
if (record.ledger.action) {
|
|
const action = record.ledger.action;
|
|
// A lost stop response is retried only while admission is fenced. Boat returns its same pending ID.
|
|
const stop = action.providerStopId
|
|
? await backend.stopStatus(record, action.providerStopId)
|
|
: await backend.stop(record);
|
|
await repository.update(record, (current) => {
|
|
if (current.ledger.action?.id !== action.id) return;
|
|
current.ledger.action.providerStopId = stop.id;
|
|
if (stop.status === "completed") {
|
|
current.ledger.action = null;
|
|
if (current.ledger.status === "detaching")
|
|
current.ledger.status = "detached";
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (liveOwners(record.ledger).length) {
|
|
await backend.renew(record);
|
|
return;
|
|
}
|
|
if (record.ledger.status === "detached") return;
|
|
const status = await backend.inspect(record);
|
|
if (["stopped", "archived"].includes(status.state)) {
|
|
if (record.ledger.status === "detaching")
|
|
await repository.update(record, (current) => {
|
|
current.ledger.status = "detached";
|
|
});
|
|
return;
|
|
}
|
|
const actionId = randomUUID();
|
|
const claimed = await repository.update(record, (current) => {
|
|
if (current.ledger.action || liveOwners(current.ledger).length)
|
|
return false;
|
|
current.ledger.action = {
|
|
kind: "stop",
|
|
id: actionId,
|
|
providerStopId: null,
|
|
};
|
|
return true;
|
|
});
|
|
if (!claimed) return;
|
|
const stop = await backend.stop(record);
|
|
await repository.update(record, (current) => {
|
|
if (current.ledger.action?.id === actionId)
|
|
current.ledger.action.providerStopId = stop.id;
|
|
});
|
|
}
|
|
async function reconcile() {
|
|
const failures: unknown[] = [];
|
|
for (const record of await repository.all())
|
|
try {
|
|
await reconcileRecord(record);
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
if (failures.length)
|
|
throw new AggregateError(
|
|
failures,
|
|
"Computer reconciliation requires retry",
|
|
);
|
|
}
|
|
return {
|
|
async list(companyId: string) {
|
|
return (await repository.all())
|
|
.filter((record) => record.companyId === companyId)
|
|
.map((record) => ({
|
|
computerId: record.id,
|
|
environmentId: record.environmentId,
|
|
sandboxId: record.providerId,
|
|
status: record.ledger.status,
|
|
}));
|
|
},
|
|
attach,
|
|
admit,
|
|
admitProbe(
|
|
input: Scope & {
|
|
agentId: string;
|
|
probeId: string;
|
|
idleTimeoutMs: number;
|
|
},
|
|
) {
|
|
return admit({ ...input, sessionKey: `probe-${segment(input.probeId)}` });
|
|
},
|
|
recover,
|
|
retainWarm,
|
|
isRetired,
|
|
retire,
|
|
connect,
|
|
renewViewer,
|
|
disconnectViewer,
|
|
preview,
|
|
files,
|
|
workspaceFiles,
|
|
realizeWorkspace,
|
|
detach,
|
|
reconcile,
|
|
inspect,
|
|
};
|
|
}
|