mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
// Worker Execution owns crash-replayable Artifact upload and completion convergence.
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type {
|
||||
CompletionReceipt,
|
||||
CompletionReceiptStore,
|
||||
} from '@qinglong/local-process';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
} from '../remote-execution/executionInbox';
|
||||
import type { WorkerRemoteExecutionSession } from '../remote-execution/executionInboxProcessor';
|
||||
import type {
|
||||
WorkerRemoteLogArtifactReadLease,
|
||||
WorkerRemoteLogArtifactSource,
|
||||
} from './workerFileLogArtifactAllocator';
|
||||
import type { RemoteWorkerExecutionFence } from '@qinglong/runtime-core/remote-worker-completion';
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const COMPLETION_EVIDENCE_STATES = new Set([
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'recovery_required',
|
||||
]);
|
||||
|
||||
export interface WorkerRemoteLogArtifactUploadCommand {
|
||||
readonly workerId: RemoteWorkerExecutionFence['workerId'];
|
||||
readonly workerSessionId: RemoteWorkerExecutionFence['workerSessionId'];
|
||||
readonly workerGeneration: RemoteWorkerExecutionFence['workerGeneration'];
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly offerId: RemoteWorkerExecutionFence['offerId'];
|
||||
readonly leaseGeneration: RemoteWorkerExecutionFence['leaseGeneration'];
|
||||
readonly leaseToken: RemoteWorkerExecutionFence['leaseToken'];
|
||||
readonly expectedLeaseVersion: RemoteWorkerExecutionFence['expectedLeaseVersion'];
|
||||
readonly logArtifactId: string;
|
||||
readonly byteLength: number;
|
||||
readonly truncated: boolean | undefined;
|
||||
readonly content: AsyncIterable<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactUploadResult {
|
||||
readonly status: 'stored' | 'already_stored';
|
||||
readonly logArtifactId: string;
|
||||
readonly byteLength: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactUploader {
|
||||
upload(
|
||||
command: WorkerRemoteLogArtifactUploadCommand,
|
||||
): Promise<Readonly<WorkerRemoteLogArtifactUploadResult>>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionCompletionCommand {
|
||||
readonly offerId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly callbackSequence: number;
|
||||
readonly callbackTokenDigest: string;
|
||||
readonly result: Readonly<{
|
||||
outcome: 'succeeded' | 'failed';
|
||||
startedAtMs: number;
|
||||
finishedAtMs: number;
|
||||
exitCode: number;
|
||||
}>;
|
||||
readonly artifact: Readonly<{
|
||||
logArtifactId: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
truncated: boolean | undefined;
|
||||
}>;
|
||||
readonly executorType: string;
|
||||
readonly workerId: string;
|
||||
readonly workerSessionId: string;
|
||||
readonly workerGeneration: number;
|
||||
readonly leaseGeneration: number;
|
||||
readonly leaseToken: string;
|
||||
readonly expectedLeaseVersion: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionCompletionResult {
|
||||
readonly status: 'applied' | 'already_completed' | 'already_terminal';
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly callbackSequence: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteExecutionCompletionClient {
|
||||
complete(
|
||||
command: WorkerRemoteExecutionCompletionCommand,
|
||||
): Promise<Readonly<WorkerRemoteExecutionCompletionResult>>;
|
||||
}
|
||||
|
||||
export type WorkerRemoteCompletionStatus =
|
||||
| 'not_found'
|
||||
| 'deferred'
|
||||
| 'receipt_missing'
|
||||
| 'receipt_unavailable'
|
||||
| 'receipt_invalid'
|
||||
| 'artifact_missing'
|
||||
| 'completion_acknowledged'
|
||||
| 'already_completed'
|
||||
| 'control_plane_terminal';
|
||||
|
||||
export interface WorkerRemoteCompletionResult {
|
||||
readonly offerId: string;
|
||||
readonly status: WorkerRemoteCompletionStatus;
|
||||
readonly receiptCleanup?: 'removed' | 'already_absent' | 'pending';
|
||||
}
|
||||
|
||||
export interface WorkerRemoteCompletionCoordinatorOptions {
|
||||
readonly currentSession: () => WorkerRemoteExecutionSession | undefined;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class WorkerRemoteCompletionCoordinatorError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'artifact_response_invalid'
|
||||
| 'completion_response_invalid',
|
||||
) {
|
||||
super(`Worker remote completion coordination failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteCompletionCoordinatorError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-replayable Worker completion pipeline. The local receipt is an
|
||||
* authenticated capability, the log is uploaded before terminal mutation,
|
||||
* and receipt deletion happens only after the inbox completion barrier.
|
||||
*/
|
||||
export class WorkerRemoteCompletionCoordinator {
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteExecutionSession | undefined;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly inFlight = new Map<
|
||||
string,
|
||||
Promise<WorkerRemoteCompletionResult>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly inbox: Pick<
|
||||
WorkerRemoteExecutionInbox,
|
||||
'readOffer' | 'replaceOffer'
|
||||
>,
|
||||
private readonly receipts: Pick<CompletionReceiptStore, 'read' | 'remove'>,
|
||||
private readonly artifacts: WorkerRemoteLogArtifactSource,
|
||||
private readonly uploader: WorkerRemoteLogArtifactUploader,
|
||||
private readonly completion: WorkerRemoteExecutionCompletionClient,
|
||||
options: WorkerRemoteCompletionCoordinatorOptions,
|
||||
) {
|
||||
if (
|
||||
typeof inbox?.readOffer !== 'function' ||
|
||||
typeof inbox?.replaceOffer !== 'function' ||
|
||||
typeof receipts?.read !== 'function' ||
|
||||
typeof receipts?.remove !== 'function' ||
|
||||
typeof artifacts?.open !== 'function' ||
|
||||
typeof uploader?.upload !== 'function' ||
|
||||
typeof completion?.complete !== 'function' ||
|
||||
typeof options?.currentSession !== 'function'
|
||||
) {
|
||||
throw new WorkerRemoteCompletionCoordinatorError('invalid_configuration');
|
||||
}
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
recover(offerId: string): Promise<WorkerRemoteCompletionResult> {
|
||||
const active = this.inFlight.get(offerId);
|
||||
if (active) return active;
|
||||
const operation = this.process(offerId).finally(() => {
|
||||
if (this.inFlight.get(offerId) === operation) this.inFlight.delete(offerId);
|
||||
});
|
||||
this.inFlight.set(offerId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async process(offerId: string): Promise<WorkerRemoteCompletionResult> {
|
||||
const value = await this.inbox.readOffer(offerId);
|
||||
if (!value) return Object.freeze({ offerId, status: 'not_found' });
|
||||
let record = normalizeWorkerRemoteExecutionInboxRecord(value);
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'already_completed',
|
||||
receiptCleanup: await this.cleanup(record.offer.candidate.attemptId),
|
||||
});
|
||||
}
|
||||
if (!this.canSubmit(record)) {
|
||||
return Object.freeze({ offerId, status: 'deferred' });
|
||||
}
|
||||
|
||||
let receipt: CompletionReceipt | undefined;
|
||||
try {
|
||||
receipt = await this.receipts.read(record.offer.candidate.attemptId);
|
||||
} catch {
|
||||
return Object.freeze({ offerId, status: 'receipt_unavailable' });
|
||||
}
|
||||
if (!receipt) return Object.freeze({ offerId, status: 'receipt_missing' });
|
||||
try {
|
||||
this.authenticate(record, receipt);
|
||||
} catch {
|
||||
return Object.freeze({ offerId, status: 'receipt_invalid' });
|
||||
}
|
||||
|
||||
const artifact = await this.artifacts.open({
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
logArtifactId: record.logArtifactId!,
|
||||
});
|
||||
if (!artifact) return Object.freeze({ offerId, status: 'artifact_missing' });
|
||||
const uploaded = await this.upload(record, artifact);
|
||||
const completed = await this.completion.complete(Object.freeze({
|
||||
offerId: record.offer.offerId,
|
||||
projectId: record.offer.candidate.projectId,
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
callbackSequence: receipt.callbackSequence,
|
||||
callbackTokenDigest: record.completionReceiptTokenDigest!,
|
||||
result: Object.freeze({
|
||||
outcome: receipt.exitCode === 0 ? 'succeeded' as const : 'failed' as const,
|
||||
startedAtMs: receipt.startedAtMs,
|
||||
finishedAtMs: receipt.finishedAtMs,
|
||||
exitCode: receipt.exitCode,
|
||||
}),
|
||||
artifact: Object.freeze({
|
||||
logArtifactId: uploaded.logArtifactId,
|
||||
byteLength: uploaded.byteLength,
|
||||
sha256: uploaded.sha256,
|
||||
truncated: artifact.truncated,
|
||||
}),
|
||||
executorType: record.offer.candidate.executorType,
|
||||
workerId: record.offer.lease.workerId,
|
||||
workerSessionId: record.offer.lease.workerSessionId,
|
||||
workerGeneration: record.offer.lease.workerGeneration,
|
||||
leaseGeneration: record.offer.lease.leaseGeneration,
|
||||
leaseToken: record.offer.leaseToken,
|
||||
expectedLeaseVersion: record.offer.lease.version,
|
||||
}));
|
||||
this.assertCompletionResponse(record, receipt, completed);
|
||||
if (completed.status === 'already_terminal') {
|
||||
return Object.freeze({ offerId, status: 'control_plane_terminal' });
|
||||
}
|
||||
record = await this.markAcknowledged(record);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'completion_acknowledged',
|
||||
receiptCleanup: await this.cleanup(record.offer.candidate.attemptId),
|
||||
});
|
||||
}
|
||||
|
||||
private canSubmit(record: WorkerRemoteExecutionInboxRecord): boolean {
|
||||
if (!COMPLETION_EVIDENCE_STATES.has(record.state)) return false;
|
||||
if (
|
||||
record.state === 'recovery_required' &&
|
||||
record.recoveryReason !== 'launch_outcome_unknown'
|
||||
) return false;
|
||||
const current = this.currentSessionProvider();
|
||||
const now = this.now();
|
||||
return Boolean(
|
||||
current &&
|
||||
current.workerId === record.offer.worker.workerId &&
|
||||
current.sessionId === record.offer.worker.sessionId &&
|
||||
current.generation === record.offer.worker.generation &&
|
||||
current.status !== 'offline' &&
|
||||
current.leaseExpiresAtMs > now &&
|
||||
record.offer.lease.expiresAtMs > now &&
|
||||
record.executorStartedAtMs !== undefined &&
|
||||
record.logArtifactId !== undefined &&
|
||||
record.completionReceiptCallbackSequence !== undefined &&
|
||||
record.completionReceiptTokenDigest !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
private authenticate(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
receipt: CompletionReceipt,
|
||||
): void {
|
||||
if (
|
||||
receipt.runId !== record.offer.candidate.runId ||
|
||||
receipt.attemptId !== record.offer.candidate.attemptId ||
|
||||
receipt.callbackSequence !== record.completionReceiptCallbackSequence ||
|
||||
receipt.startedAtMs !== record.executorStartedAtMs ||
|
||||
receipt.finishedAtMs > this.now() ||
|
||||
!record.completionReceiptTokenDigest ||
|
||||
!SHA256.test(record.completionReceiptTokenDigest)
|
||||
) throw new Error('Completion receipt authority does not match');
|
||||
const token = Buffer.from(receipt.token, 'base64url');
|
||||
try {
|
||||
if (token.byteLength !== 32 || token.toString('base64url') !== receipt.token) {
|
||||
throw new Error('Completion receipt capability is not canonical');
|
||||
}
|
||||
const expected = Buffer.from(record.completionReceiptTokenDigest, 'hex');
|
||||
const actual = createHash('sha256').update(token).digest();
|
||||
if (!timingSafeEqual(expected, actual)) {
|
||||
throw new Error('Completion receipt capability does not match');
|
||||
}
|
||||
} finally {
|
||||
token.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private async upload(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
artifact: WorkerRemoteLogArtifactReadLease,
|
||||
): Promise<WorkerRemoteLogArtifactUploadResult> {
|
||||
const digest = createHash('sha256');
|
||||
let observedBytes = 0;
|
||||
const content = (async function* () {
|
||||
for await (const chunk of artifact.chunks()) {
|
||||
observedBytes += chunk.byteLength;
|
||||
digest.update(chunk);
|
||||
yield chunk;
|
||||
}
|
||||
})();
|
||||
let result: Readonly<WorkerRemoteLogArtifactUploadResult>;
|
||||
try {
|
||||
result = await this.uploader.upload(Object.freeze({
|
||||
workerId: record.offer.lease.workerId,
|
||||
workerSessionId: record.offer.lease.workerSessionId,
|
||||
workerGeneration: record.offer.lease.workerGeneration,
|
||||
projectId: record.offer.candidate.projectId,
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
offerId: record.offer.offerId,
|
||||
leaseGeneration: record.offer.lease.leaseGeneration,
|
||||
leaseToken: record.offer.leaseToken,
|
||||
expectedLeaseVersion: record.offer.lease.version,
|
||||
logArtifactId: artifact.logArtifactId,
|
||||
byteLength: artifact.byteLength,
|
||||
truncated: artifact.truncated,
|
||||
content,
|
||||
}));
|
||||
} finally {
|
||||
await artifact.close();
|
||||
}
|
||||
const actualDigest = digest.digest('hex');
|
||||
if (
|
||||
observedBytes !== artifact.byteLength ||
|
||||
(result?.status !== 'stored' && result?.status !== 'already_stored') ||
|
||||
result.logArtifactId !== artifact.logArtifactId ||
|
||||
result.byteLength !== artifact.byteLength ||
|
||||
!SHA256.test(result.sha256) ||
|
||||
result.sha256 !== actualDigest
|
||||
) {
|
||||
throw new WorkerRemoteCompletionCoordinatorError(
|
||||
'artifact_response_invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...result });
|
||||
}
|
||||
|
||||
private assertCompletionResponse(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
receipt: CompletionReceipt,
|
||||
result: Readonly<WorkerRemoteExecutionCompletionResult>,
|
||||
): void {
|
||||
if (
|
||||
!['applied', 'already_completed', 'already_terminal'].includes(result?.status) ||
|
||||
result.runId !== record.offer.candidate.runId ||
|
||||
result.attemptId !== record.offer.candidate.attemptId ||
|
||||
result.callbackSequence !== receipt.callbackSequence
|
||||
) {
|
||||
throw new WorkerRemoteCompletionCoordinatorError(
|
||||
'completion_response_invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async markAcknowledged(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
const updatedAtMs = Math.max(this.now(), record.updatedAtMs);
|
||||
const { recoveryReason: _recoveryReason, ...authority } = record;
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...authority,
|
||||
revision: record.revision + 1,
|
||||
state: 'completion_acknowledged',
|
||||
updatedAtMs,
|
||||
completionAcknowledgedAtMs: updatedAtMs,
|
||||
});
|
||||
try {
|
||||
await this.inbox.replaceOffer(next, record.revision);
|
||||
return next;
|
||||
} catch (error) {
|
||||
const current = await this.inbox.readOffer(record.offer.offerId);
|
||||
if (current) {
|
||||
const normalized = normalizeWorkerRemoteExecutionInboxRecord(current);
|
||||
if (normalized.state === 'completion_acknowledged') return normalized;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanup(
|
||||
attemptId: string,
|
||||
): Promise<'removed' | 'already_absent' | 'pending'> {
|
||||
try {
|
||||
return (await this.receipts.remove(attemptId))
|
||||
? 'removed'
|
||||
: 'already_absent';
|
||||
} catch {
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const value = this.nowProvider();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteCompletionCoordinatorError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// Worker Execution owns lease renewal, stop fencing, and completion convergence.
|
||||
import type {
|
||||
LocalProcessController,
|
||||
LocalProcessStopResult,
|
||||
} from '@qinglong/local-process';
|
||||
import type { RemoteWorkerLeaseControlResult } from '@qinglong/runtime-core/remote-worker-lease-control';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
type WorkerRemoteExecutionInboxRecord,
|
||||
} from '../remote-execution/executionInbox';
|
||||
import type { WorkerRemoteExecutionSession } from '../remote-execution/executionInboxProcessor';
|
||||
import type {
|
||||
WorkerRemoteCompletionResult,
|
||||
WorkerRemoteCompletionStatus,
|
||||
} from './workerCompletionCoordinator';
|
||||
import type { WorkerRemoteLeaseControlClient } from '../remote-execution/transport/remoteWorkerLeaseControlHttpsClient';
|
||||
|
||||
export interface WorkerRemoteExecutionControlCoordinatorOptions {
|
||||
readonly currentSession: () => WorkerRemoteExecutionSession | undefined;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export type WorkerRemoteExecutionControlResult = Readonly<{
|
||||
readonly offerId: string;
|
||||
readonly completionStatus?: WorkerRemoteCompletionStatus;
|
||||
} & (
|
||||
| { readonly status: 'not_found' }
|
||||
| { readonly status: 'completion_acknowledged' }
|
||||
| {
|
||||
readonly status: 'renewed';
|
||||
readonly leaseVersion: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
| {
|
||||
readonly status: 'stop_requested' | 'stop_unverified';
|
||||
readonly reason: NonNullable<RemoteWorkerLeaseControlResult['stop']>['reason'];
|
||||
readonly leaseVersion: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly stop: LocalProcessStopResult;
|
||||
}
|
||||
| {
|
||||
readonly status: 'terminal';
|
||||
readonly terminalStatus: NonNullable<
|
||||
RemoteWorkerLeaseControlResult['terminalStatus']
|
||||
>;
|
||||
readonly stop: LocalProcessStopResult;
|
||||
}
|
||||
| {
|
||||
readonly status: 'completion_terminal';
|
||||
readonly stop: LocalProcessStopResult;
|
||||
}
|
||||
| {
|
||||
readonly status: 'lease_expired';
|
||||
readonly stop: LocalProcessStopResult;
|
||||
readonly recoveryReason:
|
||||
| 'lease_lost_local_execution_stopped'
|
||||
| 'lease_lost_local_execution_unverified';
|
||||
}
|
||||
| { readonly status: 'session_unavailable' }
|
||||
)>;
|
||||
|
||||
export class WorkerRemoteExecutionControlCoordinatorError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'invalid_clock'
|
||||
| 'lease_response_invalid',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Worker remote execution control failed: ${reason}`, options);
|
||||
this.name = 'WorkerRemoteExecutionControlCoordinatorError';
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_TERMINAL_TRANSITIONS = new Set([
|
||||
'accepted',
|
||||
'starting_acknowledged',
|
||||
'launching',
|
||||
'started',
|
||||
'running_acknowledged',
|
||||
'start_failed',
|
||||
'recovery_required',
|
||||
]);
|
||||
|
||||
/**
|
||||
* One bounded, timer-free supervision step for an accepted remote execution.
|
||||
* Completion evidence is replayed first; live authority is then renewed or an
|
||||
* exact durable process identity is stopped before recovery evidence is stored.
|
||||
*/
|
||||
export class WorkerRemoteExecutionControlCoordinator {
|
||||
private readonly currentSessionProvider: () =>
|
||||
WorkerRemoteExecutionSession | undefined;
|
||||
private readonly nowProvider: () => number;
|
||||
private readonly inFlight = new Map<
|
||||
string,
|
||||
Promise<WorkerRemoteExecutionControlResult>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly inbox: Pick<
|
||||
WorkerRemoteExecutionInbox,
|
||||
'readOffer' | 'replaceOffer'
|
||||
>,
|
||||
private readonly completion: Pick<
|
||||
{ recover(offerId: string): Promise<WorkerRemoteCompletionResult> },
|
||||
'recover'
|
||||
>,
|
||||
private readonly leaseControl: WorkerRemoteLeaseControlClient,
|
||||
private readonly processes: Pick<LocalProcessController, 'stop'>,
|
||||
options: WorkerRemoteExecutionControlCoordinatorOptions,
|
||||
) {
|
||||
if (
|
||||
typeof inbox?.readOffer !== 'function' ||
|
||||
typeof inbox?.replaceOffer !== 'function' ||
|
||||
typeof completion?.recover !== 'function' ||
|
||||
typeof leaseControl?.control !== 'function' ||
|
||||
typeof processes?.stop !== 'function' ||
|
||||
typeof options?.currentSession !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
this.currentSessionProvider = options.currentSession;
|
||||
this.nowProvider = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
reconcile(offerId: string): Promise<WorkerRemoteExecutionControlResult> {
|
||||
const active = this.inFlight.get(offerId);
|
||||
if (active) return active;
|
||||
const operation = this.reconcileOnce(offerId).finally(() => {
|
||||
if (this.inFlight.get(offerId) === operation) this.inFlight.delete(offerId);
|
||||
});
|
||||
this.inFlight.set(offerId, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async reconcileOnce(
|
||||
offerId: string,
|
||||
): Promise<WorkerRemoteExecutionControlResult> {
|
||||
const value = await this.inbox.readOffer(offerId);
|
||||
if (!value) return Object.freeze({ offerId, status: 'not_found' as const });
|
||||
let record = normalizeWorkerRemoteExecutionInboxRecord(value);
|
||||
if (record.state === 'completion_acknowledged') {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'completion_acknowledged' as const,
|
||||
});
|
||||
}
|
||||
|
||||
let replay: WorkerRemoteCompletionResult | undefined;
|
||||
try {
|
||||
replay = await this.completion.recover(offerId);
|
||||
} catch {
|
||||
// Completion and lease transports are intentionally isolated: a failed
|
||||
// Artifact upload must not prevent the Worker from retaining authority.
|
||||
}
|
||||
if (
|
||||
replay?.status === 'completion_acknowledged' ||
|
||||
replay?.status === 'already_completed'
|
||||
) {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'completion_acknowledged' as const,
|
||||
completionStatus: replay.status,
|
||||
});
|
||||
}
|
||||
record = await this.readRequired(offerId);
|
||||
const completionStatus = replay?.status;
|
||||
if (replay?.status === 'control_plane_terminal') {
|
||||
const stop = await this.stop(record);
|
||||
await this.markControlPlaneTerminal(record);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'completion_terminal' as const,
|
||||
stop,
|
||||
completionStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
if (record.offer.lease.expiresAtMs <= now) {
|
||||
const stop = await this.stop(record);
|
||||
const recoveryReason = this.stopWasConclusive(stop)
|
||||
? 'lease_lost_local_execution_stopped' as const
|
||||
: 'lease_lost_local_execution_unverified' as const;
|
||||
await this.markLeaseLost(record, recoveryReason);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'lease_expired' as const,
|
||||
stop,
|
||||
recoveryReason,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.sessionMatches(record, now)) {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'session_unavailable' as const,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
const control = await this.leaseControl.control(Object.freeze({
|
||||
workerId: record.offer.lease.workerId,
|
||||
workerSessionId: record.offer.lease.workerSessionId,
|
||||
workerGeneration: record.offer.lease.workerGeneration,
|
||||
projectId: record.offer.candidate.projectId,
|
||||
runId: record.offer.candidate.runId,
|
||||
attemptId: record.offer.candidate.attemptId,
|
||||
offerId: record.offer.offerId,
|
||||
leaseGeneration: record.offer.lease.leaseGeneration,
|
||||
leaseToken: record.offer.leaseToken,
|
||||
expectedLeaseVersion: record.offer.lease.version,
|
||||
}));
|
||||
this.assertAuthority(record, control);
|
||||
if (control.status === 'terminal') {
|
||||
const stop = await this.stop(record);
|
||||
await this.markControlPlaneTerminal(record);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'terminal' as const,
|
||||
terminalStatus: control.terminalStatus!,
|
||||
stop,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
record = await this.persistRenewedLease(record, control);
|
||||
if (control.status === 'renewed') {
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: 'renewed' as const,
|
||||
leaseVersion: record.offer.lease.version,
|
||||
expiresAtMs: record.offer.lease.expiresAtMs,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
const stop = await this.stop(record);
|
||||
return Object.freeze({
|
||||
offerId,
|
||||
status: this.stopWasConclusive(stop)
|
||||
? 'stop_requested' as const
|
||||
: 'stop_unverified' as const,
|
||||
reason: control.stop!.reason,
|
||||
leaseVersion: record.offer.lease.version,
|
||||
expiresAtMs: record.offer.lease.expiresAtMs,
|
||||
stop,
|
||||
...(completionStatus === undefined ? {} : { completionStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
private assertAuthority(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
result: Readonly<RemoteWorkerLeaseControlResult>,
|
||||
): void {
|
||||
if (
|
||||
result.projectId !== record.offer.candidate.projectId ||
|
||||
result.runId !== record.offer.candidate.runId ||
|
||||
result.attemptId !== record.offer.candidate.attemptId ||
|
||||
result.offerId !== record.offer.offerId ||
|
||||
result.leaseGeneration !== record.offer.lease.leaseGeneration ||
|
||||
(result.status !== 'terminal' &&
|
||||
result.leaseVersion !== record.offer.lease.version + 1)
|
||||
) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError(
|
||||
'lease_response_invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRenewedLease(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
result: Readonly<RemoteWorkerLeaseControlResult>,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
if (
|
||||
result.leaseVersion === undefined ||
|
||||
result.renewedAtMs === undefined ||
|
||||
result.expiresAtMs === undefined ||
|
||||
result.renewedAtMs < record.offer.lease.renewedAtMs ||
|
||||
result.renewedAtMs < record.offer.lease.updatedAtMs ||
|
||||
result.expiresAtMs <= result.renewedAtMs
|
||||
) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError(
|
||||
'lease_response_invalid',
|
||||
);
|
||||
}
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...record,
|
||||
revision: record.revision + 1,
|
||||
updatedAtMs: Math.max(record.updatedAtMs, result.renewedAtMs),
|
||||
offer: {
|
||||
...record.offer,
|
||||
lease: {
|
||||
...record.offer.lease,
|
||||
version: result.leaseVersion,
|
||||
renewedAtMs: result.renewedAtMs,
|
||||
expiresAtMs: result.expiresAtMs,
|
||||
updatedAtMs: result.renewedAtMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await this.inbox.replaceOffer(next, record.revision);
|
||||
return next;
|
||||
} catch (error) {
|
||||
const current = await this.inbox.readOffer(record.offer.offerId);
|
||||
if (current) {
|
||||
const normalized = normalizeWorkerRemoteExecutionInboxRecord(current);
|
||||
if (normalized.offer.lease.version >= result.leaseVersion) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async markLeaseLost(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
recoveryReason:
|
||||
| 'lease_lost_local_execution_stopped'
|
||||
| 'lease_lost_local_execution_unverified',
|
||||
): Promise<void> {
|
||||
if (record.state === 'recovery_required') return;
|
||||
if (record.state === 'start_failure_acknowledged') return;
|
||||
await this.replaceRecovery(record, recoveryReason);
|
||||
}
|
||||
|
||||
private async markControlPlaneTerminal(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<void> {
|
||||
if (
|
||||
record.state === 'recovery_required' ||
|
||||
!CONTROL_PLANE_TERMINAL_TRANSITIONS.has(record.state)
|
||||
) return;
|
||||
await this.replaceRecovery(record, 'control_plane_terminal');
|
||||
}
|
||||
|
||||
private async replaceRecovery(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
recoveryReason: WorkerRemoteExecutionInboxRecord['recoveryReason'],
|
||||
): Promise<void> {
|
||||
const next = normalizeWorkerRemoteExecutionInboxRecord({
|
||||
...record,
|
||||
revision: record.revision + 1,
|
||||
state: 'recovery_required',
|
||||
recoveryReason,
|
||||
updatedAtMs: Math.max(record.updatedAtMs, this.now()),
|
||||
});
|
||||
try {
|
||||
await this.inbox.replaceOffer(next, record.revision);
|
||||
} catch (error) {
|
||||
const current = await this.inbox.readOffer(record.offer.offerId);
|
||||
if (
|
||||
current &&
|
||||
normalizeWorkerRemoteExecutionInboxRecord(current).state ===
|
||||
'recovery_required'
|
||||
) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async stop(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
): Promise<LocalProcessStopResult> {
|
||||
if (!record.executorHandle) {
|
||||
return record.state === 'launching'
|
||||
? Object.freeze({ status: 'unknown' as const, reason: 'invalid_handle' as const })
|
||||
: Object.freeze({ status: 'already_exited' as const });
|
||||
}
|
||||
return this.processes.stop(record.executorHandle);
|
||||
}
|
||||
|
||||
private stopWasConclusive(result: LocalProcessStopResult): boolean {
|
||||
return result.status === 'stopped' || result.status === 'already_exited';
|
||||
}
|
||||
|
||||
private sessionMatches(
|
||||
record: WorkerRemoteExecutionInboxRecord,
|
||||
now: number,
|
||||
): boolean {
|
||||
const session = this.currentSessionProvider();
|
||||
return Boolean(
|
||||
session &&
|
||||
session.workerId === record.offer.worker.workerId &&
|
||||
session.sessionId === record.offer.worker.sessionId &&
|
||||
session.generation === record.offer.worker.generation &&
|
||||
session.status !== 'offline' &&
|
||||
session.leaseExpiresAtMs > now
|
||||
);
|
||||
}
|
||||
|
||||
private async readRequired(
|
||||
offerId: string,
|
||||
): Promise<WorkerRemoteExecutionInboxRecord> {
|
||||
const value = await this.inbox.readOffer(offerId);
|
||||
if (!value) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError(
|
||||
'invalid_configuration',
|
||||
);
|
||||
}
|
||||
return normalizeWorkerRemoteExecutionInboxRecord(value);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
const value = this.nowProvider();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new WorkerRemoteExecutionControlCoordinatorError('invalid_clock');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// Worker Execution owns bounded local log allocation, output, and read leases.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { assertRunDispatchId } from '@qinglong/runtime-core/run-dispatch-lease';
|
||||
import type {
|
||||
WorkerRemoteExecutionOutputChunk,
|
||||
WorkerRemoteExecutionOutputSink,
|
||||
} from '../remote-execution/executionInboxProcessor';
|
||||
import type {
|
||||
WorkerRemoteLogArtifactAllocator,
|
||||
WorkerRemoteLogArtifactPreparation,
|
||||
} from '../remote-execution/executionContextMaterializer';
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
const MAXIMUM_POLICY_BYTES = 1024 * MEBIBYTE;
|
||||
const MAXIMUM_RESERVE_BYTES = 1024 * 1024 * MEBIBYTE;
|
||||
const ARTIFACT_ID_PREFIX = 'wlog-';
|
||||
const ARTIFACT_ID_DIGEST_LENGTH = 30;
|
||||
const ARTIFACT_ID_DOMAIN = 'qinglong/worker-log-artifact@v1';
|
||||
const WORKER_FILE_LOG_OUTPUT_PLAN = Symbol('worker-file-log-output-plan');
|
||||
|
||||
export type WorkerRemoteLogArtifactProfile = 'edge' | 'node';
|
||||
|
||||
export interface WorkerRemoteLogArtifactPolicy {
|
||||
readonly maximumAttemptBytes: number;
|
||||
readonly minimumFreeBytes: number;
|
||||
readonly maximumWriteChunkBytes: number;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactCapacityProbe {
|
||||
availableBytes(root: string): Promise<bigint>;
|
||||
}
|
||||
|
||||
export interface WorkerFileLogArtifactAllocatorOptions {
|
||||
readonly root: string;
|
||||
readonly policy: WorkerRemoteLogArtifactPolicy;
|
||||
readonly capacity?: WorkerRemoteLogArtifactCapacityProbe;
|
||||
}
|
||||
|
||||
export interface WorkerFileLogOutputPlan {
|
||||
readonly filePath: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly logArtifactId: string;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactReadRequest {
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly logArtifactId: string;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactReadLease {
|
||||
readonly logArtifactId: string;
|
||||
readonly byteLength: number;
|
||||
/** Undefined means the launcher's bounded truncation fact was unavailable. */
|
||||
readonly truncated: boolean | undefined;
|
||||
chunks(): AsyncIterable<Uint8Array>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkerRemoteLogArtifactSource {
|
||||
open(
|
||||
request: WorkerRemoteLogArtifactReadRequest,
|
||||
): Promise<WorkerRemoteLogArtifactReadLease | undefined>;
|
||||
}
|
||||
|
||||
type PlannedWorkerRemoteExecutionOutputSink = WorkerRemoteExecutionOutputSink & {
|
||||
readonly [WORKER_FILE_LOG_OUTPUT_PLAN]?: () => WorkerFileLogOutputPlan;
|
||||
};
|
||||
|
||||
/** Adapter-private path capability; it is non-enumerable on the output sink. */
|
||||
export function workerFileLogOutputPlan(
|
||||
output: WorkerRemoteExecutionOutputSink,
|
||||
): Readonly<WorkerFileLogOutputPlan> | undefined {
|
||||
return (output as PlannedWorkerRemoteExecutionOutputSink)[
|
||||
WORKER_FILE_LOG_OUTPUT_PLAN
|
||||
]?.();
|
||||
}
|
||||
|
||||
export class WorkerRemoteLogArtifactError extends Error {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'invalid_configuration'
|
||||
| 'invalid_request'
|
||||
| 'capacity_unavailable'
|
||||
| 'unsafe_path'
|
||||
| 'quota_exceeded'
|
||||
| 'invalid_output'
|
||||
| 'closed',
|
||||
) {
|
||||
super(`Worker remote log Artifact failed: ${reason}`);
|
||||
this.name = 'WorkerRemoteLogArtifactError';
|
||||
}
|
||||
}
|
||||
|
||||
export function workerRemoteLogArtifactPolicy(
|
||||
profile: WorkerRemoteLogArtifactProfile,
|
||||
): Readonly<WorkerRemoteLogArtifactPolicy> {
|
||||
if (profile === 'edge') {
|
||||
return Object.freeze({
|
||||
maximumAttemptBytes: 4 * MEBIBYTE,
|
||||
minimumFreeBytes: 32 * MEBIBYTE,
|
||||
maximumWriteChunkBytes: MEBIBYTE,
|
||||
});
|
||||
}
|
||||
if (profile === 'node') {
|
||||
return Object.freeze({
|
||||
maximumAttemptBytes: 64 * MEBIBYTE,
|
||||
minimumFreeBytes: 256 * MEBIBYTE,
|
||||
maximumWriteChunkBytes: MEBIBYTE,
|
||||
});
|
||||
}
|
||||
throw new WorkerRemoteLogArtifactError('invalid_configuration');
|
||||
}
|
||||
|
||||
function normalizePolicy(
|
||||
policy: WorkerRemoteLogArtifactPolicy,
|
||||
): Readonly<WorkerRemoteLogArtifactPolicy> {
|
||||
if (
|
||||
!policy ||
|
||||
!Number.isSafeInteger(policy.maximumAttemptBytes) ||
|
||||
policy.maximumAttemptBytes < 1 ||
|
||||
policy.maximumAttemptBytes > MAXIMUM_POLICY_BYTES ||
|
||||
!Number.isSafeInteger(policy.minimumFreeBytes) ||
|
||||
policy.minimumFreeBytes < 0 ||
|
||||
policy.minimumFreeBytes > MAXIMUM_RESERVE_BYTES ||
|
||||
!Number.isSafeInteger(policy.maximumWriteChunkBytes) ||
|
||||
policy.maximumWriteChunkBytes < 1 ||
|
||||
policy.maximumWriteChunkBytes > MEBIBYTE
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_configuration');
|
||||
}
|
||||
return Object.freeze({
|
||||
maximumAttemptBytes: policy.maximumAttemptBytes,
|
||||
minimumFreeBytes: policy.minimumFreeBytes,
|
||||
maximumWriteChunkBytes: policy.maximumWriteChunkBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function currentUid(): number | undefined {
|
||||
return typeof process.getuid === 'function' ? process.getuid() : undefined;
|
||||
}
|
||||
|
||||
function assertOwnedOrdinaryFile(stat: Awaited<ReturnType<FileHandle['stat']>>): void {
|
||||
const uid = currentUid();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.nlink !== 1 ||
|
||||
(uid !== undefined && stat.uid !== uid)
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
}
|
||||
|
||||
async function privateDirectory(directory: string): Promise<Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
}>> {
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const before = await fs.lstat(directory);
|
||||
const uid = currentUid();
|
||||
if (
|
||||
!before.isDirectory() ||
|
||||
before.isSymbolicLink() ||
|
||||
(uid !== undefined && before.uid !== uid)
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
await fs.chmod(directory, 0o700);
|
||||
const after = await fs.lstat(directory);
|
||||
if (
|
||||
!after.isDirectory() ||
|
||||
after.isSymbolicLink() ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
(after.mode & 0o777) !== 0o700 ||
|
||||
(uid !== undefined && after.uid !== uid)
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
return Object.freeze({ dev: after.dev, ino: after.ino });
|
||||
}
|
||||
|
||||
function requestId(name: string, value: unknown): string {
|
||||
try {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeError('invalid ID');
|
||||
}
|
||||
assertRunDispatchId(name, value);
|
||||
return value;
|
||||
} catch {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_request');
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkerRemoteLogArtifactId(request: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offerId: string;
|
||||
}>): string {
|
||||
if (!request || typeof request !== 'object') {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_request');
|
||||
}
|
||||
const values = [
|
||||
requestId('projectId', request.projectId),
|
||||
requestId('runId', request.runId),
|
||||
requestId('attemptId', request.attemptId),
|
||||
requestId('offerId', request.offerId),
|
||||
];
|
||||
const digest = createHash('sha256');
|
||||
digest.update(ARTIFACT_ID_DOMAIN, 'utf8');
|
||||
for (const value of values) {
|
||||
digest.update('\0', 'utf8');
|
||||
digest.update(value, 'utf8');
|
||||
}
|
||||
const artifactId = `${ARTIFACT_ID_PREFIX}${digest.digest('hex').slice(
|
||||
0,
|
||||
ARTIFACT_ID_DIGEST_LENGTH,
|
||||
)}`;
|
||||
assertRunDispatchId('logArtifactId', artifactId);
|
||||
return artifactId;
|
||||
}
|
||||
|
||||
class FileSystemCapacityProbe implements WorkerRemoteLogArtifactCapacityProbe {
|
||||
async availableBytes(root: string): Promise<bigint> {
|
||||
const stat = await fs.statfs(root, { bigint: true });
|
||||
return stat.bavail * stat.bsize;
|
||||
}
|
||||
}
|
||||
|
||||
class WorkerFileLogOutput implements WorkerRemoteExecutionOutputSink {
|
||||
private pending: Promise<unknown> = Promise.resolve();
|
||||
private closeOperation: Promise<void> | undefined;
|
||||
private state: 'open' | 'closing' | 'closed' = 'open';
|
||||
private remainingBytes: number;
|
||||
|
||||
constructor(
|
||||
readonly logArtifactId: string,
|
||||
private readonly file: FileHandle,
|
||||
maximumBytes: number,
|
||||
existingBytes: number,
|
||||
private readonly maximumWriteChunkBytes: number,
|
||||
outputFilePath: string,
|
||||
) {
|
||||
this.remainingBytes = maximumBytes - existingBytes;
|
||||
Object.defineProperty(this, WORKER_FILE_LOG_OUTPUT_PLAN, {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: () => Object.freeze({
|
||||
filePath: outputFilePath,
|
||||
maximumBytes,
|
||||
logArtifactId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
write(output: WorkerRemoteExecutionOutputChunk): Promise<void> {
|
||||
if (this.state !== 'open') {
|
||||
return Promise.reject(new WorkerRemoteLogArtifactError('closed'));
|
||||
}
|
||||
if (
|
||||
!output ||
|
||||
typeof output !== 'object' ||
|
||||
(output.stream !== 'stdout' && output.stream !== 'stderr') ||
|
||||
!(output.chunk instanceof Uint8Array) ||
|
||||
output.chunk.byteLength > this.maximumWriteChunkBytes ||
|
||||
!Number.isSafeInteger(output.observedAtMs) ||
|
||||
output.observedAtMs < 0
|
||||
) {
|
||||
return Promise.reject(new WorkerRemoteLogArtifactError('invalid_output'));
|
||||
}
|
||||
const chunk = Buffer.from(output.chunk);
|
||||
const operation = this.pending.then(async () => {
|
||||
if (chunk.byteLength === 0) return;
|
||||
if (this.remainingBytes <= 0) {
|
||||
throw new WorkerRemoteLogArtifactError('quota_exceeded');
|
||||
}
|
||||
const accepted = chunk.subarray(
|
||||
0,
|
||||
Math.min(chunk.byteLength, this.remainingBytes),
|
||||
);
|
||||
let offset = 0;
|
||||
while (offset < accepted.byteLength) {
|
||||
const result = await this.file.write(accepted.subarray(offset));
|
||||
if (result.bytesWritten < 1) {
|
||||
throw new Error('Worker remote log Artifact write made no progress');
|
||||
}
|
||||
offset += result.bytesWritten;
|
||||
this.remainingBytes -= result.bytesWritten;
|
||||
}
|
||||
if (accepted.byteLength !== chunk.byteLength) {
|
||||
throw new WorkerRemoteLogArtifactError('quota_exceeded');
|
||||
}
|
||||
});
|
||||
this.pending = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
if (this.closeOperation) return this.closeOperation;
|
||||
this.state = 'closing';
|
||||
this.closeOperation = (async () => {
|
||||
await this.pending.catch(() => undefined);
|
||||
let syncError: unknown;
|
||||
try {
|
||||
await this.file.datasync();
|
||||
} catch (error) {
|
||||
syncError = error;
|
||||
}
|
||||
try {
|
||||
await this.file.close();
|
||||
} finally {
|
||||
this.state = 'closed';
|
||||
}
|
||||
if (syncError !== undefined) throw syncError;
|
||||
})();
|
||||
return this.closeOperation;
|
||||
}
|
||||
}
|
||||
|
||||
class WorkerFileLogReadLease implements WorkerRemoteLogArtifactReadLease {
|
||||
private consumed = false;
|
||||
private closeOperation: Promise<void> | undefined;
|
||||
|
||||
constructor(
|
||||
readonly logArtifactId: string,
|
||||
readonly byteLength: number,
|
||||
readonly truncated: boolean | undefined,
|
||||
private readonly file: FileHandle,
|
||||
private readonly chunkBytes: number,
|
||||
) {}
|
||||
|
||||
chunks(): AsyncIterable<Uint8Array> {
|
||||
if (this.consumed) {
|
||||
throw new WorkerRemoteLogArtifactError('closed');
|
||||
}
|
||||
this.consumed = true;
|
||||
const file = this.file;
|
||||
const byteLength = this.byteLength;
|
||||
const chunkBytes = this.chunkBytes;
|
||||
const close = () => this.close();
|
||||
return (async function* () {
|
||||
let offset = 0;
|
||||
try {
|
||||
while (offset < byteLength) {
|
||||
const buffer = Buffer.allocUnsafe(
|
||||
Math.min(chunkBytes, byteLength - offset),
|
||||
);
|
||||
const result = await file.read(buffer, 0, buffer.length, offset);
|
||||
if (result.bytesRead < 1) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
offset += result.bytesRead;
|
||||
yield Buffer.from(buffer.subarray(0, result.bytesRead));
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.closeOperation ??= this.file.close();
|
||||
return this.closeOperation;
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as NodeJS.ErrnoException).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function readTruncationFact(
|
||||
directory: string,
|
||||
request: WorkerRemoteLogArtifactReadRequest,
|
||||
maximumBytes: number,
|
||||
): Promise<boolean | undefined> {
|
||||
const target = path.join(
|
||||
directory,
|
||||
`.${request.logArtifactId}.log.truncated.json`,
|
||||
);
|
||||
let file: FileHandle;
|
||||
try {
|
||||
file = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
if (isCode(error, 'ELOOP')) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
assertOwnedOrdinaryFile(stat);
|
||||
if (stat.size < 1 || stat.size > 4096 || (stat.mode & 0o777) !== 0o600) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(stat.size + 1);
|
||||
const result = await file.read(bytes, 0, bytes.length, 0);
|
||||
if (result.bytesRead !== stat.size) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(bytes.subarray(0, result.bytesRead).toString('utf8'));
|
||||
} catch {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
if (
|
||||
JSON.stringify(keys) !== JSON.stringify([
|
||||
'attemptId', 'logArtifactId', 'maximumBytes', 'observedAtMs',
|
||||
'quotaReached', 'runId', 'schemaVersion',
|
||||
]) ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.runId !== request.runId ||
|
||||
record.attemptId !== request.attemptId ||
|
||||
record.logArtifactId !== request.logArtifactId ||
|
||||
record.maximumBytes !== maximumBytes ||
|
||||
typeof record.quotaReached !== 'boolean' ||
|
||||
!Number.isSafeInteger(record.observedAtMs) ||
|
||||
(record.observedAtMs as number) < 0
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
return record.quotaReached;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerFileLogArtifactAllocator
|
||||
implements WorkerRemoteLogArtifactAllocator, WorkerRemoteLogArtifactSource {
|
||||
private readonly root: string;
|
||||
private readonly policy: Readonly<WorkerRemoteLogArtifactPolicy>;
|
||||
private readonly capacity: WorkerRemoteLogArtifactCapacityProbe;
|
||||
|
||||
constructor(options: WorkerFileLogArtifactAllocatorOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options.root !== 'string' ||
|
||||
!path.isAbsolute(options.root) ||
|
||||
options.root.includes('\0') ||
|
||||
(options.capacity !== undefined &&
|
||||
typeof options.capacity.availableBytes !== 'function')
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_configuration');
|
||||
}
|
||||
this.root = path.resolve(options.root);
|
||||
this.policy = normalizePolicy(options.policy);
|
||||
this.capacity = options.capacity ?? new FileSystemCapacityProbe();
|
||||
}
|
||||
|
||||
async prepare(request: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
offerId: string;
|
||||
}>): Promise<WorkerRemoteLogArtifactPreparation> {
|
||||
const logArtifactId = createWorkerRemoteLogArtifactId(request);
|
||||
const rootIdentity = await privateDirectory(this.root);
|
||||
let availableBytes: bigint;
|
||||
try {
|
||||
availableBytes = await this.capacity.availableBytes(this.root);
|
||||
} catch {
|
||||
throw new WorkerRemoteLogArtifactError('capacity_unavailable');
|
||||
}
|
||||
if (typeof availableBytes !== 'bigint' || availableBytes < 0n) {
|
||||
throw new WorkerRemoteLogArtifactError('capacity_unavailable');
|
||||
}
|
||||
const requiredBytes = BigInt(this.policy.minimumFreeBytes) +
|
||||
BigInt(this.policy.maximumAttemptBytes);
|
||||
if (availableBytes < requiredBytes) {
|
||||
throw new WorkerRemoteLogArtifactError('capacity_unavailable');
|
||||
}
|
||||
const rootAfterCapacity = await fs.lstat(this.root);
|
||||
if (
|
||||
rootAfterCapacity.dev !== rootIdentity.dev ||
|
||||
rootAfterCapacity.ino !== rootIdentity.ino
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
const shard = logArtifactId.slice(
|
||||
ARTIFACT_ID_PREFIX.length,
|
||||
ARTIFACT_ID_PREFIX.length + 2,
|
||||
);
|
||||
const directory = path.join(this.root, shard);
|
||||
const directoryIdentity = await privateDirectory(directory);
|
||||
const outputFilePath = path.join(directory, `${logArtifactId}.log`);
|
||||
let file: FileHandle;
|
||||
try {
|
||||
file = await fs.open(
|
||||
outputFilePath,
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_APPEND |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ELOOP') {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
assertOwnedOrdinaryFile(stat);
|
||||
if (
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 0 ||
|
||||
stat.size > this.policy.maximumAttemptBytes
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('quota_exceeded');
|
||||
}
|
||||
await file.chmod(0o600);
|
||||
const pathStat = await fs.lstat(outputFilePath);
|
||||
const directoryAfterOpen = await fs.lstat(directory);
|
||||
if (
|
||||
pathStat.isSymbolicLink() ||
|
||||
pathStat.dev !== stat.dev ||
|
||||
pathStat.ino !== stat.ino ||
|
||||
(pathStat.mode & 0o777) !== 0o600 ||
|
||||
directoryAfterOpen.dev !== directoryIdentity.dev ||
|
||||
directoryAfterOpen.ino !== directoryIdentity.ino
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
const output = new WorkerFileLogOutput(
|
||||
logArtifactId,
|
||||
file,
|
||||
this.policy.maximumAttemptBytes,
|
||||
stat.size,
|
||||
this.policy.maximumWriteChunkBytes,
|
||||
outputFilePath,
|
||||
);
|
||||
let ownership: 'prepared' | 'handed_off' | 'released' = 'prepared';
|
||||
return Object.freeze({
|
||||
logArtifactId,
|
||||
takeOutput() {
|
||||
if (ownership !== 'prepared') {
|
||||
throw new WorkerRemoteLogArtifactError('closed');
|
||||
}
|
||||
ownership = 'handed_off';
|
||||
return output;
|
||||
},
|
||||
async release() {
|
||||
if (ownership === 'released' || ownership === 'handed_off') return;
|
||||
ownership = 'released';
|
||||
await output.close();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await file.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async open(
|
||||
request: WorkerRemoteLogArtifactReadRequest,
|
||||
): Promise<WorkerRemoteLogArtifactReadLease | undefined> {
|
||||
const runId = requestId('runId', request?.runId);
|
||||
const attemptId = requestId('attemptId', request?.attemptId);
|
||||
const logArtifactId = requestId('logArtifactId', request?.logArtifactId);
|
||||
if (!/^wlog-[0-9a-f]{30}$/.test(logArtifactId)) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_request');
|
||||
}
|
||||
const rootIdentity = await privateDirectory(this.root);
|
||||
const directory = path.join(this.root, logArtifactId.slice(5, 7));
|
||||
const directoryIdentity = await privateDirectory(directory);
|
||||
const target = path.join(directory, `${logArtifactId}.log`);
|
||||
let file: FileHandle;
|
||||
try {
|
||||
file = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
if (isCode(error, 'ELOOP')) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
assertOwnedOrdinaryFile(stat);
|
||||
if (
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 0 ||
|
||||
stat.size > this.policy.maximumAttemptBytes ||
|
||||
(stat.mode & 0o777) !== 0o600
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('invalid_output');
|
||||
}
|
||||
const pathStat = await fs.lstat(target);
|
||||
const rootAfterOpen = await fs.lstat(this.root);
|
||||
const directoryAfterOpen = await fs.lstat(directory);
|
||||
if (
|
||||
pathStat.isSymbolicLink() ||
|
||||
pathStat.dev !== stat.dev ||
|
||||
pathStat.ino !== stat.ino ||
|
||||
rootAfterOpen.dev !== rootIdentity.dev ||
|
||||
rootAfterOpen.ino !== rootIdentity.ino ||
|
||||
directoryAfterOpen.dev !== directoryIdentity.dev ||
|
||||
directoryAfterOpen.ino !== directoryIdentity.ino
|
||||
) {
|
||||
throw new WorkerRemoteLogArtifactError('unsafe_path');
|
||||
}
|
||||
const truncated = await readTruncationFact(
|
||||
directory,
|
||||
{ runId, attemptId, logArtifactId },
|
||||
this.policy.maximumAttemptBytes,
|
||||
);
|
||||
return new WorkerFileLogReadLease(
|
||||
logArtifactId,
|
||||
stat.size,
|
||||
truncated,
|
||||
file,
|
||||
Math.min(this.policy.maximumWriteChunkBytes, 64 * 1024),
|
||||
);
|
||||
} catch (error) {
|
||||
await file.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Worker Execution owns the fenced POSIX launch adapter and spawn barrier.
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
LocalProcessLaunchError,
|
||||
LocalProcessLauncher,
|
||||
type LocalProcessIdentityProvider,
|
||||
} from '@qinglong/local-process';
|
||||
import { assertRunDispatchId } from '@qinglong/runtime-core/run-dispatch-lease';
|
||||
import {
|
||||
normalizeWorkerRemoteExecutionInboxRecord,
|
||||
type WorkerRemoteExecutionInbox,
|
||||
} from '../remote-execution/executionInbox';
|
||||
import type {
|
||||
WorkerRemoteExecutionExecutor,
|
||||
WorkerRemoteExecutionLaunch,
|
||||
} from '../remote-execution/executionInboxProcessor';
|
||||
import { workerFileLogOutputPlan } from './workerFileLogArtifactAllocator';
|
||||
|
||||
export interface WorkerRemoteExecutionSpawnBarrier {
|
||||
verify(input: Readonly<{
|
||||
offerId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
callbackSequence: number;
|
||||
callbackTokenDigest: string;
|
||||
logArtifactId: string;
|
||||
executorStartedAtMs: number;
|
||||
}>): Promise<void>;
|
||||
}
|
||||
|
||||
export class WorkerInboxExecutionSpawnBarrier
|
||||
implements WorkerRemoteExecutionSpawnBarrier {
|
||||
constructor(private readonly inbox: Pick<WorkerRemoteExecutionInbox, 'readOffer'>) {
|
||||
if (!inbox || typeof inbox.readOffer !== 'function') {
|
||||
throw new TypeError('Worker execution spawn barrier inbox is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async verify(input: Readonly<{
|
||||
offerId: string;
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
callbackSequence: number;
|
||||
callbackTokenDigest: string;
|
||||
logArtifactId: string;
|
||||
executorStartedAtMs: number;
|
||||
}>): Promise<void> {
|
||||
const record = await this.inbox.readOffer(input.offerId);
|
||||
if (!record) throw new Error('Worker execution spawn barrier is missing');
|
||||
const current = normalizeWorkerRemoteExecutionInboxRecord(record);
|
||||
if (
|
||||
current.state !== 'launching' ||
|
||||
current.offer.offerId !== input.offerId ||
|
||||
current.offer.candidate.runId !== input.runId ||
|
||||
current.offer.candidate.attemptId !== input.attemptId ||
|
||||
current.completionReceiptCallbackSequence !== input.callbackSequence ||
|
||||
current.completionReceiptTokenDigest !== input.callbackTokenDigest ||
|
||||
current.logArtifactId !== input.logArtifactId ||
|
||||
current.executorStartedAtMs !== input.executorStartedAtMs
|
||||
) {
|
||||
throw new Error('Worker execution spawn barrier authority drifted');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkerPosixExecutionExecutorOptions {
|
||||
readonly barrier: WorkerRemoteExecutionSpawnBarrier;
|
||||
readonly receiptRoot: string;
|
||||
readonly launcherPath?: string;
|
||||
readonly expectedLauncherSha256?: string;
|
||||
readonly identityProvider?: LocalProcessIdentityProvider;
|
||||
readonly clock?: { now(): number };
|
||||
readonly createHandleId?: () => string;
|
||||
}
|
||||
|
||||
export class WorkerPosixExecutionExecutor
|
||||
implements WorkerRemoteExecutionExecutor {
|
||||
private readonly options: WorkerPosixExecutionExecutorOptions;
|
||||
|
||||
constructor(options: WorkerPosixExecutionExecutorOptions) {
|
||||
if (!options || typeof options.barrier?.verify !== 'function') {
|
||||
throw new TypeError('Worker POSIX Executor barrier is invalid');
|
||||
}
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async start(launch: WorkerRemoteExecutionLaunch): Promise<
|
||||
| Readonly<{
|
||||
status: 'started';
|
||||
executorHandle: string;
|
||||
executorStartedAtMs: number;
|
||||
}>
|
||||
| Readonly<{ status: 'rejected' }>
|
||||
> {
|
||||
let callbackToken: Buffer | undefined;
|
||||
try {
|
||||
assertRunDispatchId('offerId', launch.offerId);
|
||||
assertRunDispatchId('runId', launch.runId);
|
||||
assertRunDispatchId('attemptId', launch.attemptId);
|
||||
assertRunDispatchId('logArtifactId', launch.logArtifactId);
|
||||
if (
|
||||
(launch.timeoutMs === undefined) !==
|
||||
(launch.executionDeadlineAtMs === undefined) ||
|
||||
(launch.executionDeadlineAtMs !== undefined &&
|
||||
(!Number.isSafeInteger(launch.executionDeadlineAtMs) ||
|
||||
launch.executionDeadlineAtMs < 0))
|
||||
) return this.reject(launch);
|
||||
const outputPlan = workerFileLogOutputPlan(launch.output);
|
||||
if (
|
||||
!outputPlan ||
|
||||
outputPlan.logArtifactId !== launch.logArtifactId
|
||||
) return this.reject(launch);
|
||||
callbackToken = Buffer.from(launch.completionCallback.token);
|
||||
if (callbackToken.byteLength !== 32) return this.reject(launch);
|
||||
const callbackTokenDigest = createHash('sha256')
|
||||
.update(callbackToken)
|
||||
.digest('hex');
|
||||
const environment: Record<string, string> = {};
|
||||
for (const entry of launch.environment) {
|
||||
if (Object.hasOwn(environment, entry.name)) return this.reject(launch);
|
||||
environment[entry.name] = entry.value;
|
||||
}
|
||||
await launch.output.close();
|
||||
const launcher = new LocalProcessLauncher(
|
||||
{
|
||||
register: async (registered: Readonly<{
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
registeredAtMs: number;
|
||||
}>) => {
|
||||
if (
|
||||
registered.runId !== launch.runId ||
|
||||
registered.attemptId !== launch.attemptId
|
||||
) throw new Error('Worker POSIX Executor journal authority drifted');
|
||||
await this.options.barrier.verify(Object.freeze({
|
||||
offerId: launch.offerId,
|
||||
runId: launch.runId,
|
||||
attemptId: launch.attemptId,
|
||||
callbackSequence: launch.completionCallback.sequence,
|
||||
callbackTokenDigest,
|
||||
logArtifactId: launch.logArtifactId,
|
||||
executorStartedAtMs: launch.executorStartedAtMs,
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
receiptRoot: this.options.receiptRoot,
|
||||
...(this.options.launcherPath === undefined
|
||||
? {}
|
||||
: { launcherPath: this.options.launcherPath }),
|
||||
...(this.options.expectedLauncherSha256 === undefined
|
||||
? {}
|
||||
: { expectedLauncherSha256: this.options.expectedLauncherSha256 }),
|
||||
...(this.options.identityProvider === undefined
|
||||
? {}
|
||||
: { identityProvider: this.options.identityProvider }),
|
||||
...(this.options.clock === undefined
|
||||
? {}
|
||||
: { clock: this.options.clock }),
|
||||
...(this.options.createHandleId === undefined
|
||||
? {}
|
||||
: { createHandleId: this.options.createHandleId }),
|
||||
},
|
||||
);
|
||||
const command = launch.command.kind === 'argv'
|
||||
? Object.freeze({
|
||||
kind: 'argv' as const,
|
||||
file: launch.command.file,
|
||||
args: launch.command.args,
|
||||
})
|
||||
: Object.freeze({
|
||||
kind: 'shell' as const,
|
||||
command: launch.command.command,
|
||||
...(launch.command.shell === undefined
|
||||
? {}
|
||||
: { shell: launch.command.shell as '/bin/sh' | '/bin/bash' }),
|
||||
});
|
||||
const handle = await launcher.start({
|
||||
runId: launch.runId,
|
||||
attemptId: launch.attemptId,
|
||||
startedAtMs: launch.executorStartedAtMs,
|
||||
callbackSequence: launch.completionCallback.sequence,
|
||||
callbackToken: callbackToken.toString('base64url'),
|
||||
command,
|
||||
environment,
|
||||
...(launch.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: launch.workingDirectory }),
|
||||
output: outputPlan,
|
||||
});
|
||||
void handle.completion;
|
||||
return Object.freeze({
|
||||
status: 'started' as const,
|
||||
executorHandle: handle.durableHandle,
|
||||
executorStartedAtMs: handle.startedAtMs,
|
||||
});
|
||||
} catch (error) {
|
||||
await launch?.output?.close?.().catch(() => undefined);
|
||||
if (
|
||||
error instanceof LocalProcessLaunchError &&
|
||||
error.spawnOutcome === 'unknown'
|
||||
) throw error;
|
||||
return Object.freeze({ status: 'rejected' as const });
|
||||
} finally {
|
||||
callbackToken?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private async reject(
|
||||
launch: WorkerRemoteExecutionLaunch,
|
||||
): Promise<Readonly<{ status: 'rejected' }>> {
|
||||
await launch.output.close().catch(() => undefined);
|
||||
return Object.freeze({ status: 'rejected' as const });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user