Files
stack/packages/mosaic/src/commands/promote.ts
T

285 lines
9.2 KiB
TypeScript

import { spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { open } from 'node:fs/promises';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
TmuxPromotionTransport,
type PromotionTarget,
type PromotionTransport,
} from '../fleet/promotion-transport.js';
export type { PromotionTransport } from '../fleet/promotion-transport.js';
import { resolveFleetPaths, type CommandRunner } from './fleet.js';
const ATTEMPT_ID_PATTERN = /^[a-f0-9]{64}$/;
const DEFAULT_POLL_INTERVAL_MS = 250;
const DEFAULT_TIMEOUT_MS = 30_000;
const PENDING_DIRECTORY = 'mosaic-lease';
const RESULT_FILE = 'last-result.json';
export interface PromotionBreadcrumb {
attempt_id: string;
expires_at_wallclock: number | null;
reason: string | null;
session_id: string;
ts: number;
verified: boolean;
}
export interface PromotionBreadcrumbStore {
readAttemptId(sessionId: string): Promise<string | null>;
readResult(): Promise<PromotionBreadcrumb | null>;
}
export interface PromotionResult {
bundle: string;
expiresAtWallclock: number | null;
reason: string | null;
seat: string;
sessionId: string;
status: 'VERIFIED' | 'UNVERIFIED';
}
export interface PromoteSeatOptions {
clock?: () => number;
pollIntervalMs?: number;
sleep?: (milliseconds: number) => Promise<void>;
store: PromotionBreadcrumbStore;
timeoutMs?: number;
transport: PromotionTransport;
}
export interface PromoteCommandDeps {
mosaicHome?: string;
runner?: CommandRunner;
store?: PromotionBreadcrumbStore;
transport?: PromotionTransport;
}
/** Private, local result store shared with the in-seat completion hook. */
export class FilePromotionBreadcrumbStore implements PromotionBreadcrumbStore {
constructor(private readonly runtimeDirectory = defaultRuntimeDirectory()) {}
async readAttemptId(sessionId: string): Promise<string | null> {
if (!ATTEMPT_ID_PATTERN.test(sessionId)) return null;
const content = await readPrivateFile(
join(this.runtimeDirectory, PENDING_DIRECTORY, `pending-${sessionId}`),
);
const attemptId = content?.trim();
return attemptId !== undefined && ATTEMPT_ID_PATTERN.test(attemptId) ? attemptId : null;
}
async readResult(): Promise<PromotionBreadcrumb | null> {
const content = await readPrivateFile(
join(this.runtimeDirectory, PENDING_DIRECTORY, RESULT_FILE),
);
if (content === null) return null;
try {
return parseBreadcrumb(JSON.parse(content) as unknown);
} catch {
return null;
}
}
}
/** Drives one bounded, non-interactive in-seat promotion attempt. */
export async function promoteSeat(
seat: string,
options: PromoteSeatOptions,
): Promise<PromotionResult> {
const clock = options.clock ?? wallClockSeconds;
const sleep = options.sleep ?? defaultSleep;
const timeoutMs = normalizeTimeout(options.timeoutMs);
const pollIntervalMs = normalizePollInterval(options.pollIntervalMs);
const target = await options.transport.resolve(seat);
const previousAttemptId = await options.store.readAttemptId(target.sessionId);
const preSendTimestamp = clock();
try {
await options.transport.sendPromotion(target);
} catch (error: unknown) {
return unverified(target, `DELIVERY_FAILED: ${errorMessage(error)}`);
}
const deadline = preSendTimestamp + timeoutMs / 1_000;
let attemptId: string | null = null;
while (true) {
const currentAttemptId = await options.store.readAttemptId(target.sessionId);
if (currentAttemptId !== null && currentAttemptId !== previousAttemptId) {
attemptId = currentAttemptId;
}
if (attemptId !== null || previousAttemptId === null) {
const breadcrumb = await options.store.readResult();
if (
breadcrumb !== null &&
breadcrumb.session_id === target.sessionId &&
(attemptId === null || breadcrumb.attempt_id === attemptId) &&
breadcrumb.ts > preSendTimestamp
) {
return {
bundle: target.bundle,
expiresAtWallclock: breadcrumb.expires_at_wallclock,
reason: breadcrumb.reason,
seat: target.seat,
sessionId: target.sessionId,
status: breadcrumb.verified ? 'VERIFIED' : 'UNVERIFIED',
};
}
}
if (clock() >= deadline) return unverified(target, 'PROMOTION_TIMEOUT');
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - clock()) * 1_000));
}
}
export function registerPromoteCommand(program: Command, deps: PromoteCommandDeps = {}): void {
const mosaicHome = deps.mosaicHome ?? resolveFleetPaths().mosaicHome;
const transport =
deps.transport ?? new TmuxPromotionTransport({ mosaicHome, runner: deps.runner ?? runCommand });
const store = deps.store ?? new FilePromotionBreadcrumbStore();
program
.command('promote <seat>')
.description('Promote a Claude fleet seat and report the correlated lease result')
.option(
'--timeout <ms>',
`Bounded result wait in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`,
)
.action(async (seat: string, opts: { timeout?: string }) => {
const result = await promoteSeat(seat, {
store,
timeoutMs: parseOptionTimeout(opts.timeout),
transport,
});
const expiry =
result.expiresAtWallclock === null
? 'none'
: new Date(result.expiresAtWallclock * 1_000).toISOString();
const reason = result.reason === null ? '' : ` reason=${result.reason}`;
console.log(
`${result.status} seat=${result.seat} session=${result.sessionId} bundle=${result.bundle} expiry=${expiry}${reason}`,
);
if (result.status === 'UNVERIFIED') process.exitCode = 1;
});
}
function defaultRuntimeDirectory(): string {
const configured = process.env['XDG_RUNTIME_DIR'];
if (configured) return configured;
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
return `/run/user/${uid}`;
}
async function readPrivateFile(path: string): Promise<string | null> {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch {
return null;
}
try {
const metadata = await handle.stat();
if (
!metadata.isFile() ||
metadata.uid !== (typeof process.getuid === 'function' ? process.getuid() : 0) ||
(metadata.mode & 0o077) !== 0
) {
return null;
}
return handle.readFile({ encoding: 'utf8' });
} catch {
return null;
} finally {
await handle.close();
}
}
function parseBreadcrumb(value: unknown): PromotionBreadcrumb | null {
if (!isRecord(value) || Object.keys(value).length !== 6) return null;
const { attempt_id, expires_at_wallclock, reason, session_id, ts, verified } = value;
if (
typeof attempt_id !== 'string' ||
!ATTEMPT_ID_PATTERN.test(attempt_id) ||
typeof verified !== 'boolean' ||
typeof session_id !== 'string' ||
!ATTEMPT_ID_PATTERN.test(session_id) ||
typeof ts !== 'number' ||
!Number.isFinite(ts) ||
(expires_at_wallclock !== null &&
(typeof expires_at_wallclock !== 'number' || !Number.isFinite(expires_at_wallclock))) ||
(reason !== null && typeof reason !== 'string')
) {
return null;
}
return { attempt_id, expires_at_wallclock, reason, session_id, ts, verified };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeTimeout(value: number | undefined): number {
return value !== undefined && Number.isFinite(value) ? Math.max(0, value) : DEFAULT_TIMEOUT_MS;
}
function normalizePollInterval(value: number | undefined): number {
return value !== undefined && Number.isFinite(value)
? Math.max(1, value)
: DEFAULT_POLL_INTERVAL_MS;
}
function parseOptionTimeout(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function unverified(target: PromotionTarget, reason: string): PromotionResult {
return {
bundle: target.bundle,
expiresAtWallclock: null,
reason,
seat: target.seat,
sessionId: target.sessionId,
status: 'UNVERIFIED',
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function wallClockSeconds(): number {
return Date.now() / 1_000;
}
function defaultSleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function runCommand(
command: string,
args: string[],
): Promise<{
exitCode: number;
stderr: string;
stdout: string;
}> {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error: Error) => {
resolve({ exitCode: 127, stderr: error.message, stdout });
});
child.on('close', (code: number | null) => {
resolve({ exitCode: code ?? 1, stderr, stdout });
});
});
}