122 lines
4.4 KiB
TypeScript
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 };
|
|
});
|
|
}
|