blob: 8b79d8c7ca543dbe9a77ebf4500c5d6e1a521ee2 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { formatUiMessage, type UiLocale } from '@maka/core/ui-locale';
import {
connectExistingRuntimeHost,
type RuntimeHostConnection,
type RuntimeHostProfile,
} from '@maka/runtime-host/client';
import {
readLocalHostDeploymentRecord,
resolveRuntimeHostManagedDeploymentAuthority,
withLocalHostDeploymentAuthority,
} from '@maka/runtime-host/operator';
import { isProductReleaseVersion } from '@maka/runtime-host/operator/update-package-evidence';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_PROTOCOL_VERSION,
type HostDiagnosticsResult,
} from '@maka/runtime-host/protocol';
import type { MakaPiTuiHostControl } from './pi-tui-contracts.js';
import { resolveRuntimeHostNpmGlobalInstallation } from './runtime-host-cli-installation.js';
import { runRuntimeHostInstalledUpdateBootstrap } from './runtime-host-installed-update-bootstrap.js';
import { retireRuntimeHostLifecycleOwner } from './runtime-host-lifecycle-transaction.js';
import { openRuntimeHostNpmGlobalStagedDeployment } from './runtime-host-local-handoff.js';
import { launchRuntimeHostTargetActivator } from './runtime-host-local-target-activation.js';
import { getTuiHostOwnerCopy } from './tui-host-owner-copy.js';
type OwnerAction = Parameters<MakaPiTuiHostControl['prepare']>[0];
interface TuiOwnerDeps {
resolveInstallation: typeof resolveRuntimeHostNpmGlobalInstallation;
readRecord: typeof readLocalHostDeploymentRecord;
resolveManagedAuthority: typeof resolveRuntimeHostManagedDeploymentAuthority;
connectExisting: typeof connectExistingRuntimeHost;
withAuthority: typeof withLocalHostDeploymentAuthority;
retire: typeof retireRuntimeHostLifecycleOwner;
openStaged: typeof openRuntimeHostNpmGlobalStagedDeployment;
activate: typeof launchRuntimeHostTargetActivator;
update: typeof runRuntimeHostInstalledUpdateBootstrap;
}
/** A thin owner adapter. The caller disconnects its reconnecting client before execute. */
export async function prepareTuiHostOwnerAction(
input: {
profile: RuntimeHostProfile;
connection: RuntimeHostConnection;
rootPath: string;
action: OwnerAction;
locale?: UiLocale;
confirm: (detail: string) => Promise<'cancel' | 'safe' | 'interrupt'>;
},
overrides: Partial<TuiOwnerDeps> = {},
): Promise<(() => Promise<number>) | undefined> {
const locale = input.locale ?? 'en';
const copy = getTuiHostOwnerCopy(locale);
if (input.profile.kind !== 'local') {
throw new Error(copy.foreign);
}
const deps: TuiOwnerDeps = {
resolveInstallation: resolveRuntimeHostNpmGlobalInstallation,
readRecord: readLocalHostDeploymentRecord,
resolveManagedAuthority: resolveRuntimeHostManagedDeploymentAuthority,
connectExisting: connectExistingRuntimeHost,
withAuthority: withLocalHostDeploymentAuthority,
retire: retireRuntimeHostLifecycleOwner,
openStaged: openRuntimeHostNpmGlobalStagedDeployment,
activate: launchRuntimeHostTargetActivator,
update: runRuntimeHostInstalledUpdateBootstrap,
...overrides,
};
const installation = await deps.resolveInstallation();
const rootId = input.connection.rootId;
const hostEpoch = input.connection.hostEpoch;
if (await deps.resolveManagedAuthority(rootId)) throw new Error(copy.notOwner);
const record = await deps.readRecord(rootId);
if (
record?.state.kind !== 'owned' ||
record.state.owner.kind !== 'cli' ||
record.state.owner.installationId !== installation.owner.installationId
) {
throw new Error(copy.notOwner);
}
const observed = await deps.connectExisting({
rootPath: input.rootPath,
protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION },
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
});
try {
if (
observed.kind !== 'connected' ||
observed.registration.rootId !== rootId ||
observed.registration.hostEpoch !== hostEpoch ||
observed.registration.lifecycleMode !== 'ephemeral'
) {
throw new Error(copy.hostChanged);
}
} finally {
if (observed.kind === 'connected') await observed.connection.close();
}
const diagnostics = await input.connection
.request('host.diagnostics.query', {})
.catch(() => undefined);
const fingerprint = activityFingerprint(diagnostics);
let allowInterruptActiveTasks = false;
if (!diagnostics || diagnostics.upgradeBlockingActivity) {
const choice = await input.confirm(
[
formatUiMessage(
copy.actionSummary,
{ action: copy[`action_${input.action.action}`], name: input.profile.name },
locale,
),
formatUiMessage(copy.owner, { owner: installation.owner.installationId }, locale),
formatUiMessage(copy.root, { root: rootId }, locale),
formatUiMessage(copy.epoch, { epoch: hostEpoch }, locale),
diagnostics
? formatUiMessage(
copy.activity,
{
connections: diagnostics.connections,
operations: diagnostics.activeOperations,
residencies: JSON.stringify(diagnostics.residencies),
},
locale,
)
: copy.unknownActivity,
input.action.action === 'update' ? copy.updateSafeOnly : '',
]
.filter(Boolean)
.join('\n'),
);
if (choice === 'cancel') return undefined;
allowInterruptActiveTasks = choice === 'interrupt';
}
const target = input.action.target ?? 'latest';
if (
input.action.action === 'update' &&
target !== 'latest' &&
target !== 'next' &&
!isProductReleaseVersion(target)
)
throw new Error(copy.invalidTarget);
if (input.action.action === 'update' && allowInterruptActiveTasks) {
throw new Error(copy.updateRequiresSafe);
}
return async () => {
if (!isDeepStrictEqual(await deps.resolveInstallation(), installation)) {
throw new Error(copy.installationChanged);
}
if (input.action.action === 'update') {
if (await deps.resolveManagedAuthority(rootId)) throw new Error(copy.notOwner);
return deps.update({
rootPath: input.rootPath,
selector:
target === 'latest' || target === 'next'
? { kind: 'channel', channel: target }
: { kind: 'exact', version: target },
allowInterruptActiveTasks: false,
expectedSource: {
rootId,
deploymentRevision: record.revision,
ownerInstallationId: installation.owner.installationId,
hostEpoch,
},
});
}
return deps.withAuthority(rootId, async (authority, inheritableAuthorityLeaseFd) => {
if (!isDeepStrictEqual(await authority.read(), record)) {
throw new Error(copy.ownerChanged);
}
// Same-artifact restart is a process lifecycle operation, not an update.
// Open the already verified selected package; never restage/reinstall it.
const staged =
input.action.action === 'restart'
? await deps.openStaged({
rootId,
owner: installation.owner,
target: record.state.selected,
transactionId: `tui-restart:${randomUUID()}`,
})
: undefined;
if (await deps.resolveManagedAuthority(rootId)) throw new Error(copy.notOwner);
const retired = await deps.retire({
rootPath: input.rootPath,
rootId,
allowInterruptActiveTasks,
connectExisting: async (request) => {
const current = await deps.connectExisting(request);
if (
current.kind !== 'connected' ||
current.registration.rootId !== rootId ||
current.registration.hostEpoch !== hostEpoch ||
current.registration.lifecycleMode !== 'ephemeral'
) {
if (current.kind === 'connected') await current.connection.close();
throw new Error(copy.retirementHostChanged);
}
const currentDiagnostics = await current.connection
.request('host.diagnostics.query', {})
.catch(() => undefined);
if (
allowInterruptActiveTasks &&
activityFingerprint(currentDiagnostics) !== fingerprint
) {
await current.connection.close();
throw new Error(copy.activityChanged);
}
return current;
},
});
if (retired.kind === 'active_tasks') {
throw new Error(formatUiMessage(copy.stillActive, { action: input.action.action }, locale));
}
await retired.owner.close();
if (staged) {
const activated = await deps.activate({
rootPath: input.rootPath,
rootId,
staged,
ownerInstallationId: installation.owner.installationId,
target: record.state.selected,
inheritableAuthorityLeaseFd,
});
if (activated.kind !== 'ready')
throw new Error(formatUiMessage(copy.restartFailed, { detail: activated.kind }, locale));
await activated.settle();
}
return 0;
});
};
}
function activityFingerprint(diagnostics: HostDiagnosticsResult | undefined): string {
if (!diagnostics) return 'unknown';
return JSON.stringify([
diagnostics.hostEpoch,
diagnostics.pid,
diagnostics.state,
diagnostics.connections,
diagnostics.activeOperations,
diagnostics.residencies,
diagnostics.upgradeBlockingActivity,
]);
}
export async function describeTuiHost(input: {
profile: RuntimeHostProfile;
connection: RuntimeHostConnection;
locale?: UiLocale;
}): Promise<string> {
const locale = input.locale ?? 'en';
const copy = getTuiHostOwnerCopy(locale);
const status = await input.connection.status();
return [
formatUiMessage(
copy.status,
{ name: input.profile.name, state: copy[`state_${status.state}`] },
locale,
),
formatUiMessage(copy.root, { root: input.connection.rootId }, locale),
formatUiMessage(copy.epoch, { epoch: status.hostEpoch }, locale),
formatUiMessage(
copy.composition,
{ composition: status.compositionId, revision: status.compositionRevision },
locale,
),
formatUiMessage(
copy.activity,
{
connections: status.connections,
operations: status.activeOperations,
residencies: status.activeResidencies,
},
locale,
),
input.profile.kind === 'local' ? copy.localOwnerHint : copy.attachedOwnerHint,
].join('\n');
}