feat(runtime): establish miniapp kernel and sdk design
This commit is contained in:
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* LineUp Runtime 的唯一协调器。
|
||||
*
|
||||
* Runtime 独占 Transport、Conversation Store、sync loop 和 outbox;它在入站时完成旧协议
|
||||
* 兼容、scope 校验、去重、持久化和 App Inbox 投递,再经 SDK 向 Chat 提供受限投影。
|
||||
*/
|
||||
import {
|
||||
parseEnvelopeJSON,
|
||||
type Actor,
|
||||
type ConversationItem,
|
||||
type Envelope,
|
||||
type JsonObject,
|
||||
} from "@/runtime/protocol/lineup-v1";
|
||||
import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry";
|
||||
import {
|
||||
type AgentChatMessage,
|
||||
type ChatAction,
|
||||
type ChatRuntimeSDK,
|
||||
type ChatViewModel,
|
||||
type CommandReceipt,
|
||||
type ExtensionRuntimeSDK,
|
||||
type RuntimeAppEvent,
|
||||
type Unsubscribe,
|
||||
} from "@/runtime/app-management/app-sdk";
|
||||
import {
|
||||
ConversationStore,
|
||||
type ConversationSnapshot,
|
||||
type OutboxEntry,
|
||||
} from "@/runtime/persistence/conversation-store";
|
||||
import { InteractionKernel, type KernelSnapshot } from "@/runtime/coordination/interaction-kernel";
|
||||
import { resolveIncomingScope, type ScopedConversationItem } from "@/runtime/coordination/runtime-envelope";
|
||||
import {
|
||||
HttpTransportAdapter,
|
||||
type LoginRequest,
|
||||
type LoginResult,
|
||||
type TransportAdapter,
|
||||
type TransportMessage,
|
||||
} from "@/runtime/communication/transport-adapter";
|
||||
import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instance-manager";
|
||||
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
||||
import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state";
|
||||
import type { TaskRecord } from "@/runtime/coordination/task-state";
|
||||
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
||||
import { AppLifecycleManager, type AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
||||
import { AppOrchestrator } from "@/runtime/coordination/app-orchestrator";
|
||||
import { ToolRouter } from "@/runtime/coordination/tool-router";
|
||||
|
||||
export type RuntimeSession = {
|
||||
api: string;
|
||||
uid: string;
|
||||
agent_uid: string;
|
||||
channel_id: string;
|
||||
channel_type: number;
|
||||
last_seq: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeLoginRequest = LoginRequest & {
|
||||
api: string;
|
||||
fallback_agent_uid: string;
|
||||
fallback_channel_id: string;
|
||||
fallback_channel_type: number;
|
||||
};
|
||||
|
||||
export type RuntimeDependencies = {
|
||||
storage: Storage;
|
||||
createTransport?: (api: string) => TransportAdapter;
|
||||
now?: () => string;
|
||||
newID?: (prefix: string) => string;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
/** Host-only sanitizer/cache hook; Runtime still owns persistence/routing. */
|
||||
prepareIncoming?: (item: ConversationItem) => ConversationItem | undefined;
|
||||
};
|
||||
|
||||
const CONTROL_ECHO_TYPES = new Set([
|
||||
"lineup.v1.tool.result",
|
||||
"lineup.v1.tool.cancel",
|
||||
"lineup.v1.ui.event",
|
||||
"lineup.v1.client.inventory",
|
||||
"lineup.v1.app.result",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The only owner of Transport, ConversationStore, sync loop and outbox.
|
||||
*
|
||||
* It deliberately has no DOM dependency. A Core App gets a narrow SDK view;
|
||||
* host rendering and platform-specific artifact cache hooks are injected at
|
||||
* the edge instead of letting the Chat UI depend on transport/storage.
|
||||
*/
|
||||
export class LineUpRuntime {
|
||||
public readonly apps = new CoreAppRegistry();
|
||||
public readonly lifecycle = new AppLifecycleManager();
|
||||
|
||||
private readonly store: ConversationStore;
|
||||
private readonly kernel = new InteractionKernel();
|
||||
private readonly listeners = new Set<(event: RuntimeAppEvent) => void>();
|
||||
private readonly chatStateListeners = new Set<(view: ChatViewModel) => void>();
|
||||
private readonly chatMessageListeners = new Set<(message: AgentChatMessage) => void>();
|
||||
private readonly createTransport: (api: string) => TransportAdapter;
|
||||
private readonly now: () => string;
|
||||
private readonly newID: (prefix: string) => string;
|
||||
private readonly sleep: (milliseconds: number) => Promise<void>;
|
||||
private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined;
|
||||
private readonly orchestrator: AppOrchestrator;
|
||||
private readonly toolRouter: ToolRouter;
|
||||
|
||||
private transport: TransportAdapter | undefined;
|
||||
private session: RuntimeSession = {
|
||||
api: "",
|
||||
uid: "",
|
||||
agent_uid: "",
|
||||
channel_id: "",
|
||||
channel_type: 2,
|
||||
last_seq: 0,
|
||||
active: false,
|
||||
};
|
||||
private syncing = false;
|
||||
private syncRequested = false;
|
||||
|
||||
public constructor(dependencies: RuntimeDependencies) {
|
||||
this.store = new ConversationStore(dependencies.storage);
|
||||
this.createTransport = dependencies.createTransport ?? (api => new HttpTransportAdapter(api));
|
||||
this.now = dependencies.now ?? (() => new Date().toISOString());
|
||||
this.newID = dependencies.newID ?? defaultID;
|
||||
this.sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => globalThis.setTimeout(resolve, milliseconds)));
|
||||
this.prepareIncoming = dependencies.prepareIncoming ?? (item => item);
|
||||
this.orchestrator = new AppOrchestrator(this.lifecycle, this.apps, this.newID);
|
||||
this.toolRouter = new ToolRouter(this.apps);
|
||||
}
|
||||
|
||||
public getSession(): RuntimeSession {
|
||||
return { ...this.session };
|
||||
}
|
||||
|
||||
public currentConversationID(): string | undefined {
|
||||
return this.session.active ? `${this.session.uid}:${this.session.channel_type}:${this.session.channel_id}` : undefined;
|
||||
}
|
||||
|
||||
public snapshot(): ConversationSnapshot | undefined {
|
||||
return this.session.active ? this.store.snapshot() : undefined;
|
||||
}
|
||||
|
||||
public kernelSnapshot(): KernelSnapshot {
|
||||
return this.kernel.snapshot();
|
||||
}
|
||||
|
||||
public workspaceSnapshot(): AppWorkspaceSnapshot { return this.lifecycle.snapshot(); }
|
||||
|
||||
/** Runtime-only lifecycle entry point used when a mounted App reports completion. */
|
||||
public closeAppInstance(instanceID: string, error?: string): void {
|
||||
if (!this.session.active) return;
|
||||
this.orchestrator.close(instanceID, this.now(), error);
|
||||
this.persistWorkspace();
|
||||
runtimeLog("app.lifecycle", { action: error ? "failed" : "closed", instance_id: instanceID });
|
||||
this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: error ? "failed" : "closed" });
|
||||
}
|
||||
|
||||
public onEvent(listener: (event: RuntimeAppEvent) => void): Unsubscribe {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the SDK projection for a Runtime-registered Core App.
|
||||
*
|
||||
* The first SDK implementation is still Chat-shaped, but the selection key
|
||||
* now comes from the App Registry rather than from a Chat-specific Runtime
|
||||
* entry point. New App SDK projections can be added behind this boundary.
|
||||
*/
|
||||
public openApp(appScope: AppScope): ChatRuntimeSDK {
|
||||
const app = this.apps.get(appScope);
|
||||
if (!app?.enabled) throw new Error(`The ${appScope} Core App is unavailable.`);
|
||||
if (appScope !== "chat") throw new Error(`The ${appScope} Core App SDK is not registered.`);
|
||||
return {
|
||||
app_scope: "chat",
|
||||
snapshot: () => this.chatView(),
|
||||
subscribe: listener => {
|
||||
this.chatStateListeners.add(listener);
|
||||
listener(this.chatView());
|
||||
return () => this.chatStateListeners.delete(listener);
|
||||
},
|
||||
subscribeEvents: listener => this.onEvent(event => {
|
||||
// Agent content has a separate, scope-checked SDK channel. Keeping it
|
||||
// out of the generic event stream prevents a Chat renderer from
|
||||
// accidentally consuming an unprojected delivery path.
|
||||
if (event.type !== "agent-message") listener(event);
|
||||
}),
|
||||
subscribeAgentMessages: listener => {
|
||||
this.chatMessageListeners.add(listener);
|
||||
return () => this.chatMessageListeners.delete(listener);
|
||||
},
|
||||
listAgentMessages: afterMessageID => this.listChatMessages(afterMessageID),
|
||||
acknowledgeAgentMessage: messageID => this.acknowledgeChatMessage(messageID),
|
||||
dispatch: action => this.dispatchChatAction(action),
|
||||
interactions: {
|
||||
submit: (callID, result) => this.submitToolCall(callID, result),
|
||||
cancel: (callID, reason) => this.cancelToolCall(callID, reason),
|
||||
expire: callID => this.expireToolCall(callID),
|
||||
read: callID => this.toolCalls().find(record => record.call_id === callID),
|
||||
loadDraft: callID => this.getToolDraft(callID),
|
||||
saveDraft: (callID, values) => this.saveToolDraft(callID, values),
|
||||
clearDraft: callID => this.clearToolDraft(callID),
|
||||
},
|
||||
tasks: {
|
||||
requestCancel: operationID => this.requestTaskCancel(operationID),
|
||||
read: operationID => this.tasks().find(task => task.operation_id === operationID),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens a per-instance Extension projection after Runtime has started it. */
|
||||
public openExtensionApp(appScope: AppScope, instanceID: string): ExtensionRuntimeSDK {
|
||||
const app = this.apps.get(appScope);
|
||||
const instance = this.lifecycle.instances.get(instanceID);
|
||||
const conversationID = this.requireConversationID();
|
||||
if (!app || app.kind !== "extension" || !app.enabled || !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID) {
|
||||
throw new Error("Extension App SDK is unavailable for this instance.");
|
||||
}
|
||||
return {
|
||||
app_scope: appScope, instance_id: instanceID, conversation_id: conversationID,
|
||||
listMessages: afterMessageID => this.listAppMessages(appScope, afterMessageID),
|
||||
acknowledgeMessage: messageID => this.store.acknowledgeAppInbox(appScope, messageID),
|
||||
reportResult: result => this.queueProtocolAction("app-result", "lineup.v1.app.result", { instance_id: instanceID, app_scope: appScope, result }),
|
||||
reportFailure: message => this.closeAppInstance(instanceID, boundedFailure(message)),
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
|
||||
public openChatApp(): ChatRuntimeSDK {
|
||||
return this.openApp("chat");
|
||||
}
|
||||
|
||||
public async login(request: RuntimeLoginRequest): Promise<ConversationSnapshot> {
|
||||
const transport = this.createTransport(request.api);
|
||||
const result = await transport.login({ phone: request.phone, code: request.code });
|
||||
this.stopSync();
|
||||
this.transport = transport;
|
||||
this.kernel.reset();
|
||||
this.session = {
|
||||
api: request.api,
|
||||
uid: result.uid,
|
||||
agent_uid: result.agent_uid || request.fallback_agent_uid,
|
||||
channel_id: result.channel_id || request.fallback_channel_id,
|
||||
channel_type: result.channel_type || request.fallback_channel_type,
|
||||
last_seq: 0,
|
||||
active: true,
|
||||
};
|
||||
const snapshot = this.store.open(this.requireConversationID());
|
||||
this.lifecycle.restore(snapshot.app_workspace);
|
||||
this.kernel.restoreToolCalls(snapshot.tool_calls, this.now());
|
||||
this.kernel.restoreTasks(snapshot.tasks);
|
||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||
this.store.replaceTasks(this.kernel.snapshot().tasks);
|
||||
this.session.last_seq = snapshot.cursor;
|
||||
if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) {
|
||||
this.orchestrator.launch("chat", this.requireConversationID(), this.now());
|
||||
this.persistWorkspace();
|
||||
runtimeLog("app.lifecycle", { action: "started", app_scope: "chat" });
|
||||
}
|
||||
runtimeLog("session.restored", { restored_instances: this.lifecycle.instances.list(this.requireConversationID()).length, foreground: this.lifecycle.focus.foreground() ?? "none" });
|
||||
this.emitState();
|
||||
return this.store.snapshot();
|
||||
}
|
||||
|
||||
public logout(): void {
|
||||
this.stopSync();
|
||||
this.transport = undefined;
|
||||
this.kernel.reset();
|
||||
this.store.close();
|
||||
this.session = { ...this.session, uid: "", agent_uid: "", channel_id: "", last_seq: 0, active: false };
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public startSync(): void {
|
||||
if (!this.session.active || this.syncRequested) return;
|
||||
this.syncRequested = true;
|
||||
void this.syncLoop();
|
||||
}
|
||||
|
||||
public stopSync(): void {
|
||||
this.syncRequested = false;
|
||||
}
|
||||
|
||||
/** A deterministic single drain for tests and for the production sync loop. */
|
||||
public async syncOnce(): Promise<void> {
|
||||
if (!this.session.active || !this.transport) return;
|
||||
this.emit({ type: "sync-status", status: "syncing" });
|
||||
while (this.session.active && this.transport) {
|
||||
const startMessageSeq = this.session.last_seq === 0 ? 0 : this.session.last_seq + 1;
|
||||
const messages = await this.transport.sync({
|
||||
channel_id: this.session.channel_id,
|
||||
channel_type: this.session.channel_type,
|
||||
login_uid: this.session.uid,
|
||||
start_message_seq: startMessageSeq,
|
||||
limit: 50,
|
||||
});
|
||||
if (messages.length === 0) {
|
||||
this.emit({ type: "sync-status", status: "idle" });
|
||||
return;
|
||||
}
|
||||
let advanced = false;
|
||||
for (const message of messages) {
|
||||
const previousSeq = this.session.last_seq;
|
||||
this.session.last_seq = Math.max(this.session.last_seq, message.message_seq);
|
||||
advanced ||= this.session.last_seq > previousSeq;
|
||||
this.store.advanceCursor(message.message_seq);
|
||||
this.processTransportMessage(message);
|
||||
}
|
||||
if (!advanced) {
|
||||
this.emit({ type: "sync-status", status: "idle" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async flushOutbox(): Promise<void> {
|
||||
if (this.syncing || !this.session.active || !this.transport) return;
|
||||
this.syncing = true;
|
||||
try {
|
||||
for (const entry of this.store.snapshot().outbox) {
|
||||
try {
|
||||
const sequence = await this.transport.sendText({
|
||||
from_uid: this.session.uid,
|
||||
channel_id: this.session.channel_id,
|
||||
channel_type: this.session.channel_type,
|
||||
payload: entry.payload,
|
||||
});
|
||||
this.handleOutboxSuccess(entry, sequence);
|
||||
} catch (reason) {
|
||||
this.handleOutboxFailure(entry, reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
this.emitState();
|
||||
}
|
||||
}
|
||||
|
||||
public submitToolCall(callID: string, result: JsonObject): boolean {
|
||||
const transition = this.kernel.submitToolCall(callID, this.now(), result);
|
||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||
if (transition.disposition !== "accepted") return false;
|
||||
this.store.clearToolDraft(callID);
|
||||
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status: "completed", result });
|
||||
this.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
public cancelToolCall(callID: string, reason: string): boolean {
|
||||
const transition = this.kernel.cancelToolCall(callID, reason, this.now());
|
||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||
if (transition.disposition !== "accepted") return false;
|
||||
this.store.clearToolDraft(callID);
|
||||
void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", { call_id: callID, reason });
|
||||
this.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
public expireToolCall(callID: string): boolean {
|
||||
const transition = this.kernel.expireToolCall(callID, this.now());
|
||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||
this.emitState();
|
||||
return transition.disposition === "accepted";
|
||||
}
|
||||
|
||||
public requestTaskCancel(operationID: string): boolean {
|
||||
const transition = this.kernel.requestTaskCancel(operationID, this.now());
|
||||
this.store.replaceTasks(this.kernel.snapshot().tasks);
|
||||
this.emitState();
|
||||
return transition.disposition === "accepted";
|
||||
}
|
||||
|
||||
public toolCalls(): readonly ToolCallRecord[] { return this.kernel.snapshot().tool_calls; }
|
||||
public tasks(): readonly TaskRecord[] { return this.kernel.snapshot().tasks; }
|
||||
public getToolDraft(callID: string): JsonObject | undefined { return this.store.getToolDraft(callID); }
|
||||
public saveToolDraft(callID: string, values: JsonObject): void { this.store.saveToolDraft(callID, values); }
|
||||
public clearToolDraft(callID: string): void { this.store.clearToolDraft(callID); }
|
||||
public replaceTasks(records: readonly TaskRecord[]): void { this.store.replaceTasks(records); this.emitState(); }
|
||||
public replaceExecutionSummaries(records: readonly ExecutionProgressSummary[]): void { this.store.replaceExecutionSummaries(records); }
|
||||
public replaceSurfaces(snapshot: SurfaceInstanceSnapshot): void { this.store.replaceSurfaces(snapshot); }
|
||||
public replaceCapabilityCalls(records: readonly CapabilityCallRecord[]): void { this.store.replaceCapabilityCalls(records); }
|
||||
|
||||
public acknowledgeChatMessage(messageID: string): boolean {
|
||||
return this.store.acknowledgeAppInbox("chat", messageID);
|
||||
}
|
||||
|
||||
/** Runtime-owned durable protocol result path for non-Chat host modules. */
|
||||
public async queueProtocolAction(
|
||||
kind: Exclude<OutboxEntry["kind"], "text">,
|
||||
type: string,
|
||||
payload: JsonObject,
|
||||
): Promise<CommandReceipt> {
|
||||
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
||||
const localID = this.newID(kind);
|
||||
const envelope: Envelope = {
|
||||
v: 1,
|
||||
id: this.newID("msg"),
|
||||
type,
|
||||
conversation_id: this.requireConversationID(),
|
||||
sender: { kind: "human", id: this.session.uid },
|
||||
target: { kind: "agent", id: this.session.agent_uid },
|
||||
timestamp: this.now(),
|
||||
payload,
|
||||
};
|
||||
this.store.enqueueToolAction(localID, kind, JSON.stringify(envelope), this.now());
|
||||
void this.flushOutbox();
|
||||
return { disposition: "accepted", local_id: localID };
|
||||
}
|
||||
|
||||
/** Runtime-owned ephemeral route used only for declarative inventory updates. */
|
||||
public async sendProtocolEnvelope(type: string, payload: JsonObject): Promise<void> {
|
||||
if (!this.session.active || !this.transport) return;
|
||||
const envelope: Envelope = {
|
||||
v: 1,
|
||||
id: this.newID("msg"),
|
||||
type,
|
||||
conversation_id: this.requireConversationID(),
|
||||
sender: { kind: "human", id: this.session.uid },
|
||||
target: { kind: "agent", id: this.session.agent_uid },
|
||||
timestamp: this.now(),
|
||||
payload,
|
||||
};
|
||||
await this.transport.sendText({
|
||||
from_uid: this.session.uid,
|
||||
channel_id: this.session.channel_id,
|
||||
channel_type: this.session.channel_type,
|
||||
payload: JSON.stringify(envelope),
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchChatAction(action: ChatAction): Promise<CommandReceipt> {
|
||||
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
||||
if (action.conversation_id !== this.requireConversationID()) return { disposition: "rejected", code: "scope_mismatch" };
|
||||
if (action.type === "chat.send_text") {
|
||||
const text = action.text.trim();
|
||||
if (!text) return { disposition: "rejected", code: "invalid_action" };
|
||||
const localID = action.local_id ?? this.newID("local");
|
||||
const stored = this.store.appendLocalText(localID, text, this.now());
|
||||
this.emit({ type: "local-item", item: stored.item, local_id: localID });
|
||||
this.emitState();
|
||||
void this.flushOutbox();
|
||||
return { disposition: "accepted", local_id: localID };
|
||||
}
|
||||
if (action.type === "chat.submit_interaction") {
|
||||
return this.submitToolCall(action.call_id, action.result)
|
||||
? { disposition: "accepted" }
|
||||
: { disposition: "rejected", code: "invalid_action" };
|
||||
}
|
||||
if (action.type === "chat.cancel_interaction") {
|
||||
return this.cancelToolCall(action.call_id, action.reason)
|
||||
? { disposition: "accepted" }
|
||||
: { disposition: "rejected", code: "invalid_action" };
|
||||
}
|
||||
if (action.type === "chat.report_app_result") {
|
||||
return this.queueProtocolAction("app-result", "lineup.v1.app.result", action.result);
|
||||
}
|
||||
if (action.type === "chat.cancel_surface") {
|
||||
if (!action.instance_id) return { disposition: "rejected", code: "invalid_action" };
|
||||
return this.queueProtocolAction("surface-event", "lineup.v1.ui.event", {
|
||||
instance_id: action.instance_id,
|
||||
event: "cancel",
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
return { disposition: "rejected", code: "invalid_action" };
|
||||
}
|
||||
|
||||
private processTransportMessage(message: TransportMessage): void {
|
||||
const payload = decodeTransportPayload(message.payload);
|
||||
if (message.from_uid === this.session.uid) {
|
||||
const own = parseEnvelopeJSON(payload);
|
||||
if (own.ok && CONTROL_ECHO_TYPES.has(own.envelope.type)) return;
|
||||
const restored = this.store.recordSyncedUserText(message.message_seq, payload, this.now());
|
||||
if (restored) this.emit({ type: "local-item", item: restored.item, local_id: restored.local_id });
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeResolution = resolveIncomingScope(payload, {
|
||||
app_scope: "chat", conversation_id: this.requireConversationID(),
|
||||
acceptsAppScope: scope => Boolean(this.apps.get(scope)?.enabled),
|
||||
});
|
||||
if (scopeResolution.disposition === "rejected") {
|
||||
runtimeLog("message.rejected", { stage: "scope", reason: scopeResolution.reason });
|
||||
this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload });
|
||||
return;
|
||||
}
|
||||
const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID() });
|
||||
if (route.disposition === "rejected") {
|
||||
runtimeLog("tool.rejected", { reason: route.code });
|
||||
this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload });
|
||||
return;
|
||||
}
|
||||
const result = this.kernel.ingest({ raw: payload, source: "sync", transport_id: String(message.message_seq), received_at: this.now() });
|
||||
this.store.replaceToolCalls(result.snapshot.tool_calls);
|
||||
this.store.replaceTasks(result.snapshot.tasks);
|
||||
for (const rawItem of result.items) {
|
||||
const item = this.prepareIncoming(rawItem);
|
||||
if (!item) continue;
|
||||
const receivedAt = this.now();
|
||||
if (!this.store.recordIncoming(item, message.message_seq, receivedAt)) continue;
|
||||
const messageID = item.id ?? `transport:${message.message_seq}`;
|
||||
const scoped: ScopedConversationItem = {
|
||||
message_id: messageID,
|
||||
scope: scopeResolution.scope,
|
||||
item,
|
||||
received_at: receivedAt,
|
||||
message_seq: message.message_seq,
|
||||
};
|
||||
const targetScope = route.disposition === "launch" ? route.app_scope : scopeResolution.scope.app_scope;
|
||||
this.store.enqueueAppInbox({
|
||||
message_id: messageID,
|
||||
app_scope: targetScope,
|
||||
conversation_id: scopeResolution.scope.conversation_id,
|
||||
item,
|
||||
received_at: receivedAt,
|
||||
message_seq: message.message_seq,
|
||||
});
|
||||
this.emit({ type: "agent-message", message: scoped });
|
||||
if (targetScope === "chat") this.emitChatMessage(scoped);
|
||||
}
|
||||
if (route.disposition === "launch") {
|
||||
const launched = this.orchestrator.launch(route.app_scope, this.requireConversationID(), this.now(), { instance_id: route.instance_id });
|
||||
if (launched.disposition === "foreground") {
|
||||
this.persistWorkspace();
|
||||
runtimeLog("app.lifecycle", { action: "launched", app_scope: route.app_scope, instance_id: launched.instance.instance_id, prior_instance_id: launched.previous?.instance_id ?? "none" });
|
||||
this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||
}
|
||||
}
|
||||
this.emit({ type: "state", snapshot: result.snapshot });
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
private handleOutboxSuccess(entry: OutboxEntry, sequence: number | undefined): void {
|
||||
if (entry.kind === "text") {
|
||||
if (sequence) this.store.markSubmitted(entry.local_id, sequence, this.now());
|
||||
const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item;
|
||||
this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) });
|
||||
return;
|
||||
}
|
||||
this.store.acknowledgeOutbox(entry.local_id);
|
||||
}
|
||||
|
||||
private handleOutboxFailure(entry: OutboxEntry, reason: unknown): void {
|
||||
runtimeLog("outbox.delivery_failed", { kind: entry.kind });
|
||||
if (entry.kind !== "text") return;
|
||||
const message = reason instanceof Error ? reason.message : "发送失败";
|
||||
this.store.markFailed(entry.local_id, message, this.now());
|
||||
const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item;
|
||||
this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) });
|
||||
}
|
||||
|
||||
private async syncLoop(): Promise<void> {
|
||||
while (this.syncRequested && this.session.active) {
|
||||
try {
|
||||
await this.flushOutbox();
|
||||
await this.syncOnce();
|
||||
} catch {
|
||||
if (this.session.active) this.emit({ type: "sync-status", status: "retrying" });
|
||||
}
|
||||
if (this.syncRequested && this.session.active) await this.sleep(1_500);
|
||||
}
|
||||
}
|
||||
|
||||
private chatView(): ChatViewModel {
|
||||
const snapshot = this.kernel.snapshot();
|
||||
return {
|
||||
app_scope: "chat",
|
||||
conversation_id: this.currentConversationID(),
|
||||
active: this.session.active,
|
||||
agent_presence: snapshot.agent_presence,
|
||||
tool_calls: snapshot.tool_calls,
|
||||
tasks: snapshot.tasks,
|
||||
};
|
||||
}
|
||||
|
||||
private listChatMessages(afterMessageID?: string): readonly AgentChatMessage[] {
|
||||
return this.listAppMessages("chat", afterMessageID) as readonly AgentChatMessage[];
|
||||
}
|
||||
|
||||
private listAppMessages(appScope: AppScope, afterMessageID?: string): readonly ScopedConversationItem[] {
|
||||
const entries = this.store.listAppInbox(appScope);
|
||||
const start = afterMessageID ? entries.findIndex(entry => entry.message_id === afterMessageID) + 1 : 0;
|
||||
return entries.slice(Math.max(0, start)).map(entry => ({
|
||||
message_id: entry.message_id,
|
||||
scope: { app_scope: appScope, conversation_id: entry.conversation_id },
|
||||
item: entry.item,
|
||||
received_at: entry.received_at,
|
||||
...(entry.message_seq !== undefined ? { message_seq: entry.message_seq } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
private emitChatMessage(message: ScopedConversationItem): void {
|
||||
if (message.scope.app_scope !== "chat") return;
|
||||
const scoped = message as AgentChatMessage;
|
||||
for (const listener of this.chatMessageListeners) listener(scoped);
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
const view = this.chatView();
|
||||
for (const listener of this.chatStateListeners) listener(view);
|
||||
}
|
||||
|
||||
private persistWorkspace(): void { this.store.replaceAppWorkspace(this.lifecycle.snapshot()); }
|
||||
|
||||
private emit(event: RuntimeAppEvent): void {
|
||||
for (const listener of this.listeners) listener(event);
|
||||
}
|
||||
|
||||
private requireConversationID(): string {
|
||||
const conversationID = this.currentConversationID();
|
||||
if (!conversationID) throw new Error("LineUpRuntime has no active conversation.");
|
||||
return conversationID;
|
||||
}
|
||||
}
|
||||
|
||||
function boundedFailure(value: string): string {
|
||||
const message = value.trim();
|
||||
return message ? message.slice(0, 500) : "Extension App failed.";
|
||||
}
|
||||
|
||||
/** Deliberately excludes identities, conversation IDs, message bodies and Tool arguments. */
|
||||
function runtimeLog(event: string, fields: Record<string, string | number>): void {
|
||||
console.info("[LineUp Runtime]", event, fields);
|
||||
}
|
||||
|
||||
function defaultID(prefix: string): string {
|
||||
const uuid = globalThis.crypto?.randomUUID?.();
|
||||
return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`;
|
||||
}
|
||||
|
||||
function decodeTransportPayload(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent([...atob(value)].map(character => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user