285 lines
9.2 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
}
|