blob: 13f669ff0d485898aa3df849e7a019377734fafc [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 { isActiveShellRunStatus } from '@maka/core/shell-run';
import {
ShellRunUpdateBuffer,
mergeShellRunUpdate,
projectShellRunUpdateForSession,
} from '@maka/core/shell-run-result';
import { type ShellRunUpdate } from '@maka/core/events';
import type { MakaSessionDriver } from './session-driver.js';
export interface ShellRunHydrationController {
reset(): void;
hydrate(sessionId: string): Promise<void>;
dispose(): void;
}
export function createShellRunHydrationController(input: {
driver: Pick<MakaSessionDriver, 'getSessionId'>;
applyToTranscript: (update: ShellRunUpdate, options?: { announceSettle?: boolean }) => boolean;
listShellRunUpdates?: (sessionId: string) => Promise<ShellRunUpdate[]>;
subscribeShellRunUpdates?: (listener: (update: ShellRunUpdate) => void) => () => void;
onViewChanged: () => void;
isClosed: () => boolean;
}): ShellRunHydrationController {
let ownerMappings: ShellRunUpdate[] = [];
let hydratingFor: string | undefined;
let hydrationEpoch = 0;
let hydrationRetryTimer: ReturnType<typeof setTimeout> | undefined;
const pendingUpdates = new ShellRunUpdateBuffer('cli.pi-tui-hydration-buffer');
const applyViewUpdate = (
candidate: ShellRunUpdate,
options?: { announceSettle?: boolean },
): boolean => {
const index = ownerMappings.findIndex(
(update) =>
update.sessionId === candidate.sessionId &&
update.sourceToolCallId === candidate.sourceToolCallId,
);
const merged = mergeShellRunUpdate(
index >= 0 ? ownerMappings[index] : undefined,
candidate,
'cli.pi-tui-runner',
);
const retainOwnerMapping =
merged.update.ownership.kind === 'source_owned' &&
isActiveShellRunStatus(merged.update.result.status);
if (index >= 0 && retainOwnerMapping) ownerMappings[index] = merged.update;
else if (index >= 0) ownerMappings.splice(index, 1);
else if (retainOwnerMapping) ownerMappings.push(merged.update);
return input.applyToTranscript(merged.update, options);
};
const replayPendingUpdates = (sessionId: string): boolean => {
const buffered = pendingUpdates.drain();
for (const update of buffered.updates) {
const projected = projectShellRunUpdateForSession(sessionId, ownerMappings, update);
for (const viewUpdate of projected) applyViewUpdate(viewUpdate);
}
return buffered.overflowed;
};
const reset = (): void => {
ownerMappings = [];
hydrationEpoch += 1;
if (hydrationRetryTimer !== undefined) clearTimeout(hydrationRetryTimer);
hydrationRetryTimer = undefined;
hydratingFor = undefined;
pendingUpdates.clear();
};
const runHydration = async (
sessionId: string,
epoch: number,
retryDelayMs = 250,
): Promise<void> => {
try {
const updates = await input.listShellRunUpdates?.(sessionId);
if (input.isClosed() || epoch !== hydrationEpoch || input.driver.getSessionId() !== sessionId)
return;
// Catch-up replays durable state, not a live event: flip cards silently.
// Updates buffered from the live subscription during the await are
// genuinely live and stay announceable in the drain below.
for (const update of updates ?? []) applyViewUpdate(update, { announceSettle: false });
const overflowed = replayPendingUpdates(sessionId);
input.onViewChanged();
if (overflowed) {
void runHydration(sessionId, epoch);
return;
}
hydratingFor = undefined;
} catch {
if (input.isClosed() || epoch !== hydrationEpoch || input.driver.getSessionId() !== sessionId)
return;
hydrationRetryTimer = setTimeout(() => {
hydrationRetryTimer = undefined;
void runHydration(sessionId, epoch, Math.min(retryDelayMs * 2, 5_000));
}, retryDelayMs);
}
};
const unsubscribe = input.subscribeShellRunUpdates?.((update) => {
const sessionId = input.driver.getSessionId();
if (input.isClosed() || !sessionId) return;
if (hydratingFor === sessionId) {
pendingUpdates.add(update);
return;
}
const projected = projectShellRunUpdateForSession(sessionId, ownerMappings, update);
let changed = false;
for (const viewUpdate of projected) {
if (applyViewUpdate(viewUpdate)) changed = true;
}
if (changed) input.onViewChanged();
});
return {
reset,
async hydrate(sessionId) {
hydratingFor = sessionId;
await runHydration(sessionId, hydrationEpoch);
},
dispose() {
unsubscribe?.();
reset();
},
};
}