maestro/src/bridge/space-python-packages-service.ts
oss-sync 77ee3bc426
Some checks are pending
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

122 lines
4.4 KiB
TypeScript

import type { Repository } from '../db/repository.js';
import {
installSpacePackages,
parseAndValidateSpec,
spaceKeyFor,
type ParsedSpec,
type PythonPackagesConfig,
} from '../engine/python-packages.js';
/**
* Shared per-space Python package overlay service.
*
* Both entry points that add a package to a space MUST go through here so they
* share ONE module-level mutex (`spaceLocks`) and ONE desired-state read-modify-
* write path:
* - the admin form (space-python-packages-api.ts)
* - agent approvals (local-tasks-package-requests-api.ts, PR3)
*
* Sharing the mutex is load-bearing, not just DRY: a concurrent admin-add and
* agent-approve on the same space would otherwise race the content-addressed
* overlay build and the `spaces.python_packages` JSON. The persisted desired-
* state is the source of truth; each mutation rebuilds the whole overlay and
* atomically repoints `current` (see python-packages.ts).
*/
export interface PersistedPkg {
name: string; // PEP 503 normalized
spec: string; // canonical pip argv, e.g. "requests==2.32.3"
addedAt: string;
}
export interface PersistedState {
packages: PersistedPkg[];
lockHash?: string;
updatedAt?: string;
}
export function readState(json: string | null): PersistedState {
if (!json) return { packages: [] };
try {
const v = JSON.parse(json);
if (v && Array.isArray(v.packages)) return v as PersistedState;
} catch {
// fall through
}
return { packages: [] };
}
// Per-space in-process mutex so two concurrent installs never race the overlay
// or the read-modify-write of the desired-state JSON. Module-level so it is
// shared across every caller in this process.
const spaceLocks = new Map<string, Promise<unknown>>();
export function withSpaceLock<T>(spaceId: string, fn: () => Promise<T>): Promise<T> {
const prev = spaceLocks.get(spaceId) ?? Promise.resolve();
const next = prev.catch(() => {}).then(fn);
spaceLocks.set(spaceId, next.catch(() => {}));
return next;
}
export type AddPackageOutcome =
| { ok: true; state: PersistedState }
| { conflict: true }
| { versionConflict: string } // same name, DIFFERENT spec already pinned (existing spec)
| { tooMany: true }
| { installError: string };
/**
* Add ONE already-validated package to a space's desired-state and rebuild the
* overlay under the shared per-space lock. Idempotent by name: an existing
* package returns `{ conflict: true }` without touching the overlay.
*
* Callers MUST have validated `parsed` (parseAndValidateSpec + assertNotShadowing)
* and checked the feature is enabled. This function does NOT re-authorize.
*/
export async function addPackageToSpace(params: {
repo: Repository;
cfg: PythonPackagesConfig;
spaceId: string;
ownerId: string | null | undefined;
parsed: ParsedSpec;
bwrapAvailable: boolean;
}): Promise<AddPackageOutcome> {
const { repo, cfg, spaceId, ownerId, parsed, bwrapAvailable } = params;
return withSpaceLock(spaceId, async () => {
const state = readState(repo.getSpacePythonPackages(spaceId));
const existing = state.packages.find((p) => p.name === parsed.normalizedName);
if (existing) {
// Same exact spec → idempotent no-op success. Same name but a DIFFERENT
// spec cannot be added (one version per package) — surface it distinctly
// so the caller doesn't falsely report the requested version as installed.
return existing.spec === parsed.spec
? { conflict: true as const }
: { versionConflict: existing.spec };
}
if (state.packages.length + 1 > cfg.maxPackagesPerSpace) {
return { tooMany: true as const };
}
// Rebuild the FULL set (existing + new) into a fresh content-addressed overlay.
const specs: ParsedSpec[] = [
...state.packages.map((p) => parseAndValidateSpec(p.spec)),
parsed,
];
const install = await installSpacePackages({
pkgRoot: cfg.dir,
spaceKey: spaceKeyFor(spaceId, ownerId),
specs,
cfg,
bwrapAvailable,
});
if (!install.ok) return { installError: install.error ?? 'install failed' };
const next: PersistedState = {
packages: [
...state.packages,
{ name: parsed.normalizedName, spec: parsed.spec, addedAt: new Date().toISOString() },
],
lockHash: install.lockHash,
updatedAt: new Date().toISOString(),
};
repo.setSpacePythonPackages(spaceId, JSON.stringify(next));
return { ok: true as const, state: next };
});
}