blob: c827d136f505d981579248e907fe9642f65749aa [file]
#!/usr/bin/env node
/*
* 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.
*/
// Thin controller for production TS Host at main 0d19f242336a31e8aeb300372d3d3c896b1e99ef.
// Usage: node ts-adapter.mjs --checkout PATH --root PATH --workspace PATH --provider-url http://127.0.0.1:PORT/v1
// stdin JSONL: {id,command:'setup'|'create'|'admit'|'completion'|'send'|'read'|'status'|'close'|'restart'|'exit',...}
// setup optional model; create optional sessionId/name; send requires sessionId/text, optional turnId/timeoutMs;
// read requires sessionId, optional includeRaw. close stops Host; restart reopens same durable root.
// Responses: {id,result} / {id,error}; asynchronous output: {type:'event',event,...,monoMs,wallMs}.
// Parent MUST supply a sanitized environment. All model traffic must target loopback provider.
import { pathToFileURL } from 'node:url';
import { resolve, join } from 'node:path';
import { randomUUID, createHash } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { createInterface } from 'node:readline';
import { readFile } from 'node:fs/promises';
import { diagnosticError } from './diagnostics.mjs';
const options = {};
for (let i = 2; i < process.argv.length; i += 2) {
const key = process.argv[i];
if (!key?.startsWith('--') || !process.argv[i + 1])
throw new Error('Expected paired --option value');
options[key.slice(2)] = process.argv[i + 1];
}
for (const key of ['checkout', 'root', 'workspace', 'provider-url'])
if (!options[key]) throw new Error(`Missing --${key}`);
const checkout = resolve(options.checkout),
rootPath = resolve(options.root),
workspace = resolve(options.workspace);
const providerUrl = new URL(options['provider-url']);
if (
providerUrl.protocol !== 'http:' ||
!['localhost', '127.0.0.1', '[::1]'].includes(providerUrl.hostname) ||
providerUrl.username ||
providerUrl.password
)
throw new Error('Provider must be unauthenticated loopback HTTP');
// Supported production initialization: prevents models.dev refresh and optional background helpers;
// production execution stores and transcript persistence remain enabled.
process.env.MAKA_HOSTED_INITIALIZATION = JSON.stringify({ incognito: true });
const client = await import(
pathToFileURL(join(checkout, 'packages/runtime-host/dist/client/index.js')).href
);
const protocol = await import(
pathToFileURL(join(checkout, 'packages/runtime-host/dist/protocol/index.js')).href
);
let connection, pid, hostEpoch, configured, spawnedProcess, hostStartTicks;
let connections = [],
observers = [],
requestedScopes = 1;
const output = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
const event = (name, value = {}) =>
output({ type: 'event', event: name, monoMs: performance.now(), wallMs: Date.now(), ...value });
const hash = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
const pause = (ms) => new Promise((done) => setTimeout(done, ms));
async function startTicks(processId) {
// This measurement adapter targets Linux. A PID alone is insufficient after a crash.
try {
const stat = await readFile(`/proc/${processId}/stat`, 'utf8');
return stat
.slice(stat.lastIndexOf(')') + 2)
.trim()
.split(/\s+/)[19];
} catch (error) {
if (error.code === 'ENOENT' || error.code === 'ESRCH') return null;
throw error;
}
}
async function ensureScopes(count) {
if (!Number.isInteger(count) || count < 1 || count > 10000)
throw new Error('scopes must be an integer from 1 to 10000');
while (connections.length < count) {
// Only connect to the already launched Host: additional scopes never spawn candidates.
const connected = await client.connectExistingRuntimeHost({
rootPath,
protocol: {
min: protocol.RUNTIME_HOST_PROTOCOL_VERSION,
max: protocol.RUNTIME_HOST_PROTOCOL_VERSION,
},
compositionId: protocol.INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
clientInstanceId: randomUUID(),
});
if (connected.kind !== 'connected')
throw new Error(`Additional client connection failed: ${connected.kind}`);
if (connected.connection.hostEpoch !== hostEpoch) {
await connected.connection.close();
throw new Error('Additional scope attached to a different Host');
}
connections.push(connected.connection);
}
while (connections.length > count) await connections.pop().close();
requestedScopes = count;
}
async function start(scopes = requestedScopes) {
if (connection) {
await ensureScopes(scopes);
return status();
}
process.env.MAKA_HOSTED_INITIALIZATION = JSON.stringify({ incognito: true });
const connected = await client.connectOrSpawnRuntimeHost({
rootPath,
protocol: {
min: protocol.RUNTIME_HOST_PROTOCOL_VERSION,
max: protocol.RUNTIME_HOST_PROTOCOL_VERSION,
},
compositionId: protocol.INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
candidateEntrypoint: pathToFileURL(
join(checkout, 'packages/runtime-host/dist/execution-candidate-main.js'),
),
clientInstanceId: randomUUID(),
candidateExecutable: process.execPath,
idleGraceMs: 120_000,
electionDeadlineMs: 120_000,
onExit: (details) => event('host-exit', details),
});
if (connected.kind !== 'connected')
throw new Error(`Host connection failed: ${JSON.stringify(connected)}`);
connection = connected.connection;
connections = [connection];
spawnedProcess = connected.spawnedProcess;
const deadline = performance.now() + 120_000;
while ((await connection.status()).state !== 'ready') {
if (performance.now() > deadline) throw new Error('Host readiness timeout');
await pause(25);
}
const diagnostic = await connection.request('host.diagnostics.query', {});
pid = diagnostic.pid;
hostEpoch = diagnostic.hostEpoch;
hostStartTicks = await startTicks(pid);
if (hostStartTicks === null) throw new Error('Host exited before process ownership was captured');
await ensureScopes(scopes);
// Expose authoritative production PID, never the adapter PID. No diagnostics logs on stdout.
const result = {
pid,
hostEpoch,
state: diagnostic.state,
nodeVersion: diagnostic.nodeVersion,
platform: diagnostic.platform,
scopeCount: connections.length,
adapterPid: process.pid,
spawnedPid: spawnedProcess?.pid,
};
event('host-ready', result);
return result;
}
async function status() {
if (!connection) return { connected: false, pid, hostEpoch, scopeCount: 0 };
return {
...(await connection.status()),
pid,
scopeCount: connections.length,
adapterPid: process.pid,
};
}
async function setup(input) {
const started = await start(input.scopes ?? 1);
const model = input.model ?? 'comparison-model';
const catalog = await client.readRuntimeHostConnectionCatalog(connection);
let target = catalog.connections.find((item) => item.slug === 'offline-comparison');
if (target) {
if (target.providerType !== 'custom' || target.defaultApiProtocol !== 'openai-chat-completions')
throw new Error('Existing comparison connection type differs');
const updated = await connection.request('connection.catalog.update', {
expected: { connectionId: target.connectionId, revision: target.revision },
changes: {
name: 'Offline comparison',
baseUrl: providerUrl.href,
enabled: true,
enabledModelIds: [model],
modelOverrides: { [model]: { contextWindow: 2000000 } },
},
});
if (updated.kind !== 'committed') throw new Error(`Connection update: ${updated.kind}`);
} else {
const created = await connection.request('connection.catalog.create', {
expectedCatalogRevision: catalog.revision,
connection: {
slug: 'offline-comparison',
name: 'Offline comparison',
providerType: 'custom',
defaultApiProtocol: 'openai-chat-completions',
baseUrl: providerUrl.href,
enabled: true,
enabledModelIds: [model],
modelOverrides: { [model]: { contextWindow: 2000000 } },
},
});
if (created.kind !== 'committed') throw new Error(`Connection create: ${created.kind}`);
target = created.connection;
const credential = await connection.request('credential.vault.set', {
locator: { scope: 'connection', connectionId: target.connectionId, kind: 'api_key' },
expected: null,
secret: 'offline-comparison-dummy',
});
if (credential.kind !== 'committed')
throw new Error(`Dummy credential setup: ${credential.kind}`);
}
configured = { connectionId: target.connectionId, connectionSlug: 'offline-comparison', model };
return { ...started, target: configured, providerUrl: providerUrl.href };
}
function requireConnection(input) {
if (!connection) throw new Error('Run setup or restart first');
const index = input.client ?? 0;
if (!Number.isInteger(index) || index < 0 || index >= connections.length)
throw new Error('Invalid client scope index');
return connections[index];
}
async function observe() {
if (!connection) throw new Error('Run setup before observe');
if (observers.length) throw new Error('Observer scopes already connected');
const startedMs = performance.now();
for (let index = 0; index < requestedScopes; index++) {
const connected = await client.connectExistingRuntimeHost({
rootPath,
protocol: {
min: protocol.RUNTIME_HOST_PROTOCOL_VERSION,
max: protocol.RUNTIME_HOST_PROTOCOL_VERSION,
},
compositionId: protocol.INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
clientInstanceId: randomUUID(),
handshakeTimeoutMs: 120_000,
});
if (connected.kind !== 'connected')
throw new Error(
`Observer connection failed: ${JSON.stringify({ kind: connected.kind, reason: connected.reason, connectionFailure: connected.connectionFailure })}`,
);
if (connected.connection.hostEpoch !== hostEpoch) {
await connected.connection.close();
throw new Error('Observer attached to a different Host epoch');
}
observers.push(connected.connection);
}
return {
senderScopes: connections.length,
observerScopes: observers.length,
hostEpoch,
handshakeTimeoutMsPerScope: 120_000,
elapsedMs: performance.now() - startedMs,
};
}
function observationConnection(input) {
if (!input.observer) return requireConnection(input);
const index = input.client ?? 0;
if (!Number.isInteger(index) || index < 0 || index >= observers.length)
throw new Error('Invalid observer scope index');
return observers[index];
}
async function create(input) {
const scoped = requireConnection(input);
if (!configured) throw new Error('Run setup before create');
if (!input.sessionId) throw new Error('create requires explicit sessionId');
const sessionId = input.sessionId;
const result = await scoped.request('session.create', {
sessionId,
workspace: { kind: 'host_path', path: workspace },
name: input.name ?? `comparison-${sessionId}`,
modelTarget: { kind: 'explicit', ...configured },
});
return { sessionId: result.id, session: result };
}
// Admission and observation are separate public RPCs: no UI subscription or hidden queue.
async function admit(input) {
const scoped = requireConnection(input);
if (!input.sessionId || typeof input.text !== 'string')
throw new Error('admit requires sessionId and text');
const turnId = input.turnId ?? randomUUID(),
startedMs = performance.now();
const admission = await scoped.request(
'turn.start',
{
sessionId: input.sessionId,
turnId,
content: { text: input.text },
},
input.timeoutMs ?? 120_000,
);
const admittedMs = performance.now();
return {
sessionId: input.sessionId,
turnId,
client: input.client ?? 0,
admission,
startedMs,
admittedMs,
elapsedMs: admittedMs - startedMs,
};
}
async function completion(input) {
const scoped = observationConnection(input);
if (!input.sessionId || !input.turnId)
throw new Error('completion requires sessionId and turnId');
const startedMs = performance.now();
// main protocol/turn.ts TurnQueryInput; HostTurnControlCoordinator reads admitted execution.
const rootTurn = await scoped.request(
'turn.query',
{
sessionId: input.sessionId,
turnId: input.turnId,
},
input.timeoutMs ?? 120_000,
);
const finishedMs = performance.now();
if (rootTurn.sessionId !== input.sessionId || rootTurn.turnId !== input.turnId)
throw new Error('Turn query identity mismatch');
return {
sessionId: input.sessionId,
turnId: input.turnId,
client: input.client ?? 0,
status: rootTurn.status,
terminal: ['completed', 'failed', 'cancelled'].includes(rootTurn.status),
rootTurn,
startedMs,
finishedMs,
elapsedMs: finishedMs - startedMs,
};
}
async function send(input) {
const scoped = requireConnection(input);
if (typeof input.text !== 'string' || !input.sessionId)
throw new Error('send requires sessionId and text');
const { sessionId } = input,
turnId = input.turnId ?? randomUUID(),
startedMs = performance.now();
const sub = await scoped.openSessionSubscription({ sessionId, transcript: { kind: 'none' } });
const texts = new Map();
let firstTextMs = null,
terminalMs = null,
terminal;
let resolveTerminal, rejectTerminal;
const done = new Promise((yes, no) => {
resolveTerminal = yes;
rejectTerminal = no;
});
done.catch(() => {}); // The reader can fail while turn.start is still in flight.
const consume = (async () => {
try {
for await (const frame of sub) {
const now = performance.now();
if (
frame.kind === 'subscription.session_delta' &&
frame.delta.turnId === turnId &&
frame.delta.kind === 'text'
) {
const delta = frame.delta,
old = texts.get(delta.messageId) ?? '';
if (
!Number.isInteger(delta.startOffset) ||
delta.startOffset < 0 ||
(!delta.reset && delta.startOffset > old.length)
)
throw new Error('Invalid stream offset');
const base = delta.reset ? '' : old;
texts.set(delta.messageId, base.slice(0, delta.startOffset) + delta.text);
if (delta.text.length && firstTextMs === null) firstTextMs = now;
if (input.emitEvents)
event('text', {
sessionId,
turnId,
client: input.client ?? 0,
messageId: delta.messageId,
startOffset: delta.startOffset,
utf8Bytes: Buffer.byteLength(delta.text),
reset: !!delta.reset,
complete: !!delta.complete,
receivedMs: now,
});
}
if (frame.kind === 'subscription.session_projection') {
const turn = frame.snapshot.rootTurn;
if (
turn?.turnId === turnId &&
['completed', 'failed', 'cancelled'].includes(turn.status)
) {
terminal = turn;
terminalMs = now;
if (input.emitEvents) event('terminal', { sessionId, turnId, turn, receivedMs: now });
resolveTerminal();
return;
}
}
}
if (!terminal) rejectTerminal(new Error('Subscription ended before terminal projection'));
} catch (error) {
rejectTerminal(error);
}
})();
let timer;
try {
await sub.ready();
const admission = await scoped.request('turn.start', {
sessionId,
turnId,
content: { text: input.text },
});
const admittedMs = performance.now();
if (input.emitEvents) event('admitted', { sessionId, turnId, admittedMs, admission });
await Promise.race([
done,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error('Turn terminal timeout')),
input.timeoutMs ?? 120_000,
);
}),
]);
const text = [...texts.values()].join('');
return {
sessionId,
turnId,
admission,
terminal,
startedMs,
admittedMs,
firstTextMs,
terminalMs,
text,
utf8Bytes: Buffer.byteLength(text),
sha256: hash(text),
};
} finally {
clearTimeout(timer);
await sub.close();
await consume;
}
}
async function read(input) {
const scoped = observationConnection(input);
if (!input.sessionId) throw new Error('read requires sessionId');
const startedMs = performance.now();
// Every read uses a fresh subscription: loadTranscript memoization never crosses rounds.
const sub = await scoped.openSessionSubscription({
sessionId: input.sessionId,
transcript: { kind: 'tail', maxBytes: input.tailBytes ?? 16 * 1024 },
});
try {
const openedMs = performance.now();
const tail = await sub.decodeTranscriptPage(sub.transcriptBootstrap.durable, (value) => value);
const tailDecodedMs = performance.now();
const raw = await sub.loadTranscript((value) => value);
const finishedMs = performance.now();
const messages = raw
.filter((message) => message.type === 'user' || message.type === 'assistant')
.map((message) => {
if (typeof message.text !== 'string')
throw new Error('Canonical conversation row has no text');
// Canonical original prompt is message.text; do not replace with displayText or trim output.
return { role: message.type, text: message.text };
});
const assistantText = messages
.filter((message) => message.role === 'assistant')
.map((message) => message.text)
.join('');
return {
sessionId: input.sessionId,
startedMs,
openedMs,
tailDecodedMs,
finishedMs,
tailRawBytes: sub.transcriptBootstrap.durable.rawBytes,
tailMessages: tail.messages.length,
canonicalRows: raw.length,
messages,
assistantUtf8Bytes: Buffer.byteLength(assistantText),
assistantSha256: hash(assistantText),
...(input.includeRaw ? { raw } : {}),
};
} finally {
await sub.close();
}
}
async function close() {
if (!connection) return { closed: true, pid };
const oldConnections = [...connections, ...observers],
oldPid = pid,
oldEpoch = hostEpoch;
// PID obtained through the authenticated diagnostics operation above; SIGTERM runs production drain.
await Promise.all(oldConnections.map((scope) => scope.close()));
connection = undefined;
connections = [];
observers = [];
if ((await startTicks(oldPid)) !== hostStartTicks) {
spawnedProcess = undefined;
event('host-closed', { pid: oldPid, hostEpoch: oldEpoch, alreadyExited: true });
return { closed: true, pid: oldPid, hostEpoch: oldEpoch, alreadyExited: true };
}
process.kill(oldPid, 'SIGTERM');
const deadline = performance.now() + 30_000;
if (spawnedProcess?.pid === oldPid) {
let exitTimer;
try {
await Promise.race([
spawnedProcess.exited,
new Promise((_, reject) => {
exitTimer = setTimeout(
() => reject(new Error('Host did not exit after SIGTERM')),
30_000,
);
}),
]);
} finally {
clearTimeout(exitTimer);
}
} else {
while (true) {
if ((await startTicks(oldPid)) !== hostStartTicks) break;
if (performance.now() > deadline) throw new Error('Host did not exit after SIGTERM');
await pause(25);
}
}
spawnedProcess = undefined;
event('host-closed', { pid: oldPid, hostEpoch: oldEpoch });
return { closed: true, pid: oldPid, hostEpoch: oldEpoch };
}
const commands = {
setup,
create,
admit,
completion,
send,
read,
status,
observe,
close,
restart: async () => {
await close();
return start();
},
};
event('adapter-ready', { adapterPid: process.pid, checkout, rootPath, workspace });
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
const pending = new Set();
let stopping = false;
async function dispatch(input) {
if (stopping) throw new Error('Adapter is stopping');
if (['close', 'restart', 'exit', 'setup', 'observe'].includes(input.command) && pending.size > 0)
throw new Error('Lifecycle command requires other commands to finish');
if (input.command === 'exit') {
stopping = true;
const result = await close();
lines.close();
return result;
}
const command = commands[input.command];
if (!command) throw new Error(`Unknown command: ${input.command}`);
return command(input);
}
try {
for await (const line of lines) {
if (!line.trim()) continue;
let input;
try {
input = JSON.parse(line);
} catch (error) {
output({ id: null, error: diagnosticError(error) });
continue;
}
// Dispatch without awaiting: each session owns its own subscription and completion promise.
const task = dispatch(input).then(
(result) => output({ id: input.id, result }),
(error) => output({ id: input.id, error: diagnosticError(error) }),
);
pending.add(task);
task.finally(() => pending.delete(task));
}
await Promise.all(pending);
} finally {
await close().catch((error) =>
process.stderr.write(`Host cleanup: ${JSON.stringify(diagnosticError(error))}\n`),
);
}