Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/main/input-queue.ts

typescript282 行查看原文
import { randomUUID } from "node:crypto";
import type { InputQueue, QueuedInput, RunStatus } from "@zpi-dev/ui";
import type { RunInput } from "../shared/bridge.ts";

interface QueueRuntime {
  check(id: string): void;
  running(id: string): boolean;
  prepare(input: RunInput): Promise<Omit<QueuedInput, "id" | "state">>;
  retain(id: string, item: QueuedInput, retained: boolean): Promise<void>;
  discard(id: string, item: QueuedInput): Promise<void>;
  start(input: RunInput, item?: QueuedInput): Promise<{ runId: string }>;
  stop(id: string): Promise<void>;
  accepted?(id: string, inputId: string): boolean;
  save(id: string, queue: InputQueue, edited?: QueuedInput): void;
  publish(id: string, queue: InputQueue): void;
  report?(id: string, error: unknown): void;
}
/** Desktop input delivery only. Agent prompts, tools and history remain owned by SessionHost. */
export class SessionInputQueue {
  private states = new Map<string, InputQueue>();
  private operations = new Map<string, Promise<unknown>>();
  private preempting = new Set<string>();
  private closing = false;
  private runtime: QueueRuntime;
  constructor(runtime: QueueRuntime) {
    this.runtime = runtime;
  }
  get(id: string): InputQueue {
    return this.states.get(id) ?? { items: [], autoDrain: true };
  }
  restore(id: string, state: InputQueue, acceptedIds: ReadonlySet<string> = new Set()): void {
    const items = state.items
      .filter((item) => !acceptedIds.has(item.id))
      .map((item) => {
        const restored = { ...item, state: "queued" as const };
        // 旧版误把“立即”存成 guide;未消费的项恢复为普通暂停队列,不能再次偷偷引导。
        delete restored.delivery;
        return restored;
      });
    this.states.set(id, {
      ...state,
      items,
      autoDrain: !items.length,
      ...(items.length ? { pauseReason: state.pauseReason ?? "restart" } : {}),
    });
  }
  private commit(id: string, queue: InputQueue, edited?: QueuedInput): void {
    this.runtime.save(id, queue, edited);
    this.states.set(id, queue);
    this.publish(id, queue);
  }
  private commitWithFallback(id: string, queue: InputQueue): void {
    try {
      this.commit(id, queue);
    } catch {
      this.states.set(id, queue);
      this.publish(id, queue);
    }
  }
  private publish(id: string, queue: InputQueue): void {
    try {
      this.runtime.publish(id, queue);
    } catch (error) {
      this.runtime.report?.(id, error);
    }
  }
  private updateAfterAcceptance(id: string, queue: InputQueue): boolean {
    try {
      this.commit(id, queue);
      return true;
    } catch (error) {
      // The run is already durable. Queue housekeeping must not turn its receipt
      // into a rejected draft that the user can accidentally submit a second time.
      const paused: InputQueue = {
        ...this.get(id),
        autoDrain: false,
        pauseReason: "error",
        error: error instanceof Error ? error.message : String(error),
      };
      this.commitWithFallback(id, paused);
      return false;
    }
  }
  private serial<T>(id: string, work: () => Promise<T>): Promise<T> {
    const result = (this.operations.get(id) ?? Promise.resolve())
      .catch(() => {})
      .then(() => {
        if (this.closing) throw new Error("busy: 应用正在关闭");
        this.runtime.check(id);
        return work();
      });
    this.operations.set(id, result);
    void result
      .finally(() => {
        if (this.operations.get(id) === result) this.operations.delete(id);
      })
      .catch(() => {});
    return result;
  }
  private async stopForImmediate(id: string): Promise<void> {
    this.preempting.add(id);
    try {
      await this.runtime.stop(id);
    } finally {
      this.preempting.delete(id);
    }
  }
  async submit(input: RunInput, disposition?: "keep" | "clear", immediate = false) {
    return this.serial(input.sessionId, async () => {
      if (this.closing) throw new Error("busy: 应用正在关闭");
      const id = input.sessionId,
        queue = this.get(id);
      if (immediate) {
        await this.runtime.prepare(input);
        // “立即”对齐 ZCode startNow:校验后停止当前轮,等待释放执行权,再启动新轮。
        await this.stopForImmediate(id);
        if (this.closing) throw new Error("busy: 应用正在关闭");
        const result = await this.runtime.start(input);
        if (queue.items.length)
          this.updateAfterAcceptance(id, { items: this.get(id).items, autoDrain: true });
        return result;
      }
      if (!this.runtime.running(id) && (!queue.items.length || (!queue.autoDrain && disposition))) {
        // Validate before clearing a held queue, so a failed send cannot erase accepted inputs.
        if (queue.items.length) await this.runtime.prepare(input);
        const result = await this.runtime.start(input);
        if (queue.items.length) {
          const updated = this.updateAfterAcceptance(id, {
            items: disposition === "clear" ? [] : queue.items,
            autoDrain: true,
          });
          if (updated && disposition === "clear")
            for (const item of queue.items) await this.runtime.discard(id, item).catch(() => {});
        }
        return result;
      }
      if (!this.runtime.running(id) && !queue.autoDrain && !disposition)
        return { confirmationRequired: true as const };
      const prepared = await this.runtime.prepare(input);
      if (this.closing) throw new Error("busy: 应用正在关闭");
      if (
        this.get(id).items.some((item) =>
          item.attachments.some((image) => prepared.attachments.some((incoming) => incoming.id === image.id)),
        )
      )
        throw new Error("invalid_input: 附件已属于待发送消息");
      const item: QueuedInput = {
        ...prepared,
        id: randomUUID(),
        state: "queued",
      };
      await this.runtime.retain(id, item, true);
      try {
        const current = this.get(id);
        this.commit(id, { ...current, items: [...current.items, item] });
      } catch (error) {
        await this.runtime.retain(id, item, false);
        throw error;
      }
      if (!this.runtime.running(id) && this.get(id).autoDrain) void this.resume(id).catch(() => {});
      return { queueItemId: item.id };
    });
  }
  private item(id: string, itemId: string): QueuedInput {
    const item = this.get(id).items.find((item) => item.id === itemId);
    if (!item) throw new Error("not_found: 待发送消息不存在");
    if (item.state !== "queued") throw new Error("busy: 待发送消息正在提交");
    return item;
  }
  remove(id: string, itemId: string, edit = false): Promise<QueuedInput> {
    return this.serial(id, async () => {
      const item = this.item(id, itemId),
        queue = this.get(id);
      this.commit(
        id,
        { ...queue, items: queue.items.filter((item) => item.id !== itemId) },
        edit ? item : undefined,
      );
      if (edit) await this.runtime.retain(id, item, false);
      else await this.runtime.discard(id, item).catch(() => {});
      return item;
    });
  }
  move(id: string, itemId: string, beforeId: string | null): Promise<void> {
    return this.serial(id, async () => {
      const item = this.item(id, itemId),
        queue = this.get(id);
      if (beforeId === itemId) return;
      if (beforeId !== null) this.item(id, beforeId);
      const items = queue.items.filter((item) => item.id !== itemId);
      const index = beforeId === null ? items.length : items.findIndex((item) => item.id === beforeId);
      items.splice(index, 0, item);
      this.commit(id, { ...queue, items });
    });
  }
  private async dispatch(id: string, item: QueuedInput, immediate = false): Promise<void> {
    const queue = this.get(id);
    let accepted = false;
    this.commit(id, {
      items: queue.items.map((value) => (value.id === item.id ? { ...value, state: "dispatching" } : value)),
      autoDrain: true,
    });
    try {
      if (immediate) await this.stopForImmediate(id);
      if (this.closing) throw new Error("busy: 应用正在关闭");
      await this.runtime.start(
        {
          sessionId: id,
          text: item.text,
          subagentNotification: item.subagentNotification,
          fileReferences: item.fileReferences,
          attachments: item.attachments.map((image) => image.id),
        },
        item,
      );
      accepted = true;
      this.commit(id, { items: this.get(id).items.filter((value) => value.id !== item.id), autoDrain: true });
    } catch (error) {
      accepted ||= this.runtime.accepted?.(id, item.id) ?? false;
      const paused: InputQueue = {
        // A run already accepted by the host must never be delivered again after a queue-save error.
        items: this.get(id)
          .items.filter((value) => !accepted || value.id !== item.id)
          .map((value) => (value.id === item.id ? { ...value, state: "queued" } : value)),
        autoDrain: false,
        pauseReason: "error",
        error: error instanceof Error ? error.message : String(error),
      };
      this.commitWithFallback(id, paused);
      throw error;
    }
  }
  sendNow(id: string, itemId: string): Promise<void> {
    return this.serial(id, async () => {
      await this.dispatch(id, this.item(id, itemId), true);
    });
  }
  resume(id: string): Promise<void> {
    return this.serial(id, async () => {
      if (this.closing) return;
      const queue = this.get(id);
      this.commit(id, { items: queue.items, autoDrain: true });
      if (!this.runtime.running(id) && queue.items[0]) await this.dispatch(id, queue.items[0]);
    });
  }
  settled(id: string, status: Exclude<RunStatus, "running">): void {
    if (this.preempting.has(id) || this.closing) return;
    void this.serial(id, async () => {
      if (this.closing) return;
      const queue = this.get(id);
      if (!queue.items.length) return;
      if (status !== "completed")
        this.commit(id, {
          ...queue,
          autoDrain: false,
          pauseReason: status === "error" ? "error" : "stopped",
        });
      else if (queue.autoDrain && !this.runtime.running(id)) await this.dispatch(id, queue.items[0]);
    }).catch(() => {});
  }
  async delete(id: string): Promise<void> {
    await this.operations.get(id)?.catch(() => {});
    this.states.delete(id);
  }
  archive(id: string, persist: () => void | Promise<void>): Promise<void> {
    return this.serial(id, async () => {
      if (this.runtime.running(id)) throw new Error("busy: 请先停止运行");
      const items = this.get(id).items;
      await persist();
      this.commit(id, { items: [], autoDrain: false });
      for (const item of items) await this.runtime.discard(id, item).catch(() => {});
    });
  }
  async close(): Promise<void> {
    this.closing = true;
    await Promise.allSettled(this.operations.values());
    for (const [id, queue] of this.states)
      if (queue.items.length)
        this.commit(id, { ...queue, autoDrain: false, pauseReason: queue.pauseReason ?? "restart" });
  }
}