oasis_test_v2 2.2.18 → 2.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +241 -33
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -22923,7 +22923,7 @@ function normalizeClaudeStreamLine(line) {
22923
22923
  for (const block of content3) {
22924
22924
  switch (block["type"]) {
22925
22925
  case "text":
22926
- out.push({ kind: "message", payload: { text: block["text"] } });
22926
+ if (type === "assistant") out.push({ kind: "message", payload: { text: block["text"] } });
22927
22927
  break;
22928
22928
  case "thinking":
22929
22929
  out.push({ kind: "thought", payload: { text: block["thinking"] } });
@@ -23123,6 +23123,8 @@ var init_claude_code = __esm({
23123
23123
  // 同上
23124
23124
  ["--verbose", "boolean"],
23125
23125
  // 与 --output-format stream-json 配对;单独无意义、成对是重复
23126
+ ["--replay-user-messages", "boolean"],
23127
+ // Input consumption receipts are adapter-owned.
23126
23128
  ["--include-partial-messages", "boolean"]
23127
23129
  // 同上
23128
23130
  ]);
@@ -23211,7 +23213,7 @@ var init_claude_code = __esm({
23211
23213
  // 据此喂 onOutput 实现 chat 逐字流式(同时 telemetry 仍由整条 assistant 消息归一)。
23212
23214
  ...this.opts.streamJson ? ["--output-format", "stream-json", "--verbose", "--include-partial-messages"] : [],
23213
23215
  // ADR-0093:stream-json 输入模式——让 claude 逐条从 stdin 读 user 消息(支持多轮追加)。
23214
- ...appendMode ? ["--input-format", "stream-json"] : [],
23216
+ ...appendMode ? ["--input-format", "stream-json", "--replay-user-messages"] : [],
23215
23217
  ...job.model ?? this.opts.model ? ["--model", job.model ?? this.opts.model] : [],
23216
23218
  // ADR「agent CLI 启动参数员工级可配置化」:员工配置 job.extraArgs(在前)+ opts.extraArgs(静态默认,在后),
23217
23219
  // 过 CLAUDE_BLOCKED_FLAGS 拦截打破协议契约的 flag(剔除 + warn),追加到 argv 末尾。
@@ -23279,10 +23281,11 @@ var init_claude_code = __esm({
23279
23281
  pendingClose = null;
23280
23282
  }
23281
23283
  };
23282
- const writeUserMessage = (text5) => {
23284
+ const pendingInputReceipts = /* @__PURE__ */ new Map();
23285
+ const writeUserMessage = (text5, uuid2) => {
23283
23286
  try {
23284
23287
  child.stdin.write(
23285
- JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: text5 }] } }) + "\n"
23288
+ JSON.stringify({ type: "user", ...uuid2 ? { uuid: uuid2 } : {}, message: { role: "user", content: [{ type: "text", text: text5 }] } }) + "\n"
23286
23289
  );
23287
23290
  writes++;
23288
23291
  cancelPendingClose();
@@ -23357,6 +23360,22 @@ var init_claude_code = __esm({
23357
23360
  out.write(line + "\n");
23358
23361
  try {
23359
23362
  const p2 = JSON.parse(line);
23363
+ if (p2["type"] === "user" && p2["isReplay"] === true && !p2["parent_tool_use_id"] && typeof p2["uuid"] === "string") {
23364
+ const key = pendingInputReceipts.get(p2["uuid"]);
23365
+ if (key) {
23366
+ pendingInputReceipts.delete(p2["uuid"]);
23367
+ diag("input_consumed", { key, uuid: p2["uuid"] });
23368
+ for (const cb of liveEventCbs) cb({
23369
+ protocolVersion: 2,
23370
+ streamId: `claude-live:${id}`,
23371
+ turnId: id,
23372
+ itemId: `input:${p2["uuid"]}`,
23373
+ itemType: "control",
23374
+ operation: "completed",
23375
+ payload: { code: "input_consumed", clientMessageKey: key }
23376
+ });
23377
+ }
23378
+ }
23360
23379
  if (p2["type"] === "result") {
23361
23380
  lastResult = p2;
23362
23381
  results++;
@@ -23532,6 +23551,9 @@ var init_claude_code = __esm({
23532
23551
  }
23533
23552
  };
23534
23553
  function doAppend(input) {
23554
+ if (input.requireConsumptionReceipt && (!input.clientMessageKey || !appendMode)) {
23555
+ return Promise.resolve({ accepted: false, reason: "consumption-unsupported" });
23556
+ }
23535
23557
  if (!appendMode) {
23536
23558
  diag("append_rejected", { reason: "unsupported", writes, results });
23537
23559
  return Promise.resolve({ accepted: false, reason: "unsupported", ...input.interrupt ? { interrupt: "unsupported" } : {} });
@@ -23545,9 +23567,12 @@ var init_claude_code = __esm({
23545
23567
  const outgoing = report.failed.length ? appendMaterializeWarning(input.text, report) : input.text;
23546
23568
  if (input.interrupt) writeInterrupt();
23547
23569
  diag("append_accepted", { writes, results, interrupt: !!input.interrupt, chars: input.text.length });
23548
- const ok3 = writeUserMessage(outgoing);
23570
+ const receiptId = input.requireConsumptionReceipt ? (0, import_node_crypto8.randomUUID)() : void 0;
23571
+ if (receiptId) pendingInputReceipts.set(receiptId, input.clientMessageKey);
23572
+ const ok3 = writeUserMessage(outgoing, receiptId);
23573
+ if (!ok3 && receiptId) pendingInputReceipts.delete(receiptId);
23549
23574
  if (!ok3) return Promise.resolve({ accepted: false, reason: "rejected", ...hasFiles ? { files: report } : {}, ...input.interrupt ? { interrupt: "unknown" } : {} });
23550
- return Promise.resolve({ accepted: true, ...hasFiles ? { files: report } : {}, interrupt: input.interrupt ? "interrupted" : "queued_at_boundary" });
23575
+ return Promise.resolve({ accepted: true, ...receiptId ? { consumption: "pending" } : {}, ...hasFiles ? { files: report } : {}, interrupt: input.interrupt ? "interrupted" : "queued_at_boundary" });
23551
23576
  }
23552
23577
  }
23553
23578
  };
@@ -25027,6 +25052,7 @@ async function runProtocolSession(job, cfg) {
25027
25052
  return !closing && started && !exited;
25028
25053
  },
25029
25054
  appendInput(input) {
25055
+ if (input.requireConsumptionReceipt) return Promise.resolve({ accepted: false, reason: "consumption-unsupported" });
25030
25056
  appendChain = appendChain.then(async () => {
25031
25057
  if (exited || closing) return { accepted: false, reason: "session-closing" };
25032
25058
  const files = materializeFiles(dir, input.files, input.binaryFiles);
@@ -26852,6 +26878,7 @@ ${task}` : task;
26852
26878
  }
26853
26879
  };
26854
26880
  function doAppend(input) {
26881
+ if (input.requireConsumptionReceipt) return Promise.resolve({ accepted: false, reason: "consumption-unsupported" });
26855
26882
  if (exited || closingForExit) return Promise.resolve({ accepted: false, reason: "session-closing" });
26856
26883
  const report = materializeFiles(dir, input.files, input.binaryFiles);
26857
26884
  const queued = report.failed.length ? { ...input, text: appendMaterializeWarning(input.text, report) } : input;
@@ -28507,6 +28534,18 @@ var init_normalizer = __esm({
28507
28534
  onLiveEvent(event) {
28508
28535
  this.turnId = event.turnId || this.turnId;
28509
28536
  const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload) ? event.payload : {};
28537
+ if (event.itemType === "control" && payload.code === "input_consumed" && typeof payload.clientMessageKey === "string") {
28538
+ this.closeOpen("completed");
28539
+ this.emitItem({
28540
+ key: providerItemKey(this.providerName, event.itemId),
28541
+ kind: "control",
28542
+ role: "system",
28543
+ origin: "live",
28544
+ opShape: { op: "set_status", status: "completed" },
28545
+ attrs: { code: "input_consumed", clientMessageKey: payload.clientMessageKey }
28546
+ });
28547
+ return;
28548
+ }
28510
28549
  if (event.operation === "turn_completed" || event.operation === "turn_failed") {
28511
28550
  this.turnFinished(event.operation === "turn_failed" ? "failed" : "completed");
28512
28551
  return;
@@ -28922,7 +28961,7 @@ var init_chat_item_ledger = __esm({
28922
28961
  const payload = { role: "user", text: text5 };
28923
28962
  if (attachments?.length) payload.attachments = attachments;
28924
28963
  if (clientKeys?.length) payload.clientSubmitIds = clientKeys;
28925
- this.insert({
28964
+ return this.insert({
28926
28965
  kind: "text",
28927
28966
  role: "user",
28928
28967
  status: "completed",
@@ -29072,10 +29111,10 @@ var init_chat_item_ledger = __esm({
29072
29111
  * 返回落下的那一行(`null` = 本段有输出、或还没有 assistant 行可归属,不需要注解),
29073
29112
  * 调用方据它把这条推上 v3 流——不推的话它只在下一次快照才出现。
29074
29113
  */
29075
- closeSegmentBeforeUserInput() {
29114
+ closeSegmentBeforeUserInput(allowUnboundSegment = false) {
29076
29115
  this.closeStreamingRows("completed");
29077
29116
  if (this.segmentEndEmitted) return null;
29078
- if (this.segmentItemCount !== 0 || !this.messageId) return null;
29117
+ if (this.segmentItemCount !== 0 || !this.messageId && !allowUnboundSegment) return null;
29079
29118
  const id = this.insertSegmentEnd();
29080
29119
  this.segmentEndEmitted = true;
29081
29120
  const row = this.rows.get(id);
@@ -160057,7 +160096,8 @@ var init_live_chat = __esm({
160057
160096
  v3Seq: 0,
160058
160097
  v3Buffer: [],
160059
160098
  v3Subscribers: prev?.recovering ? prev.v3Subscribers : /* @__PURE__ */ new Set(),
160060
- v3Items: /* @__PURE__ */ new Map()
160099
+ v3Items: /* @__PURE__ */ new Map(),
160100
+ inputReceipts: /* @__PURE__ */ new Map()
160061
160101
  };
160062
160102
  this.turns.set(chatSessionId, turn);
160063
160103
  this.flushPendingUserItems(chatSessionId);
@@ -160111,6 +160151,8 @@ var init_live_chat = __esm({
160111
160151
  };
160112
160152
  },
160113
160153
  finish: (status) => {
160154
+ for (const receipt of turn.inputReceipts.values()) receipt.abandon();
160155
+ turn.inputReceipts.clear();
160114
160156
  try {
160115
160157
  turn.items?.finish(status === "error" ? "failed" : "completed");
160116
160158
  } catch {
@@ -160172,6 +160214,11 @@ var init_live_chat = __esm({
160172
160214
  * 账本坏掉不影响对话;v3 emit 抛异常也不影响 v2 通路与账本落盘——两条独立通路。
160173
160215
  */
160174
160216
  applyNormalizedEvent(turn, event) {
160217
+ if (event.type === "item" && event.kind === "control" && event.attrs?.code === "input_consumed") {
160218
+ const key = event.attrs.clientMessageKey;
160219
+ if (key) turn.inputReceipts.get(key)?.consume();
160220
+ return;
160221
+ }
160175
160222
  let outcome = null;
160176
160223
  try {
160177
160224
  outcome = turn.items?.apply(event) ?? null;
@@ -160695,7 +160742,7 @@ var init_live_chat = __esm({
160695
160742
  if (turn.recovering) return say({ accepted: false, reason: "recovering" }, { recovering: true });
160696
160743
  const fn = turn.handle.appendInput;
160697
160744
  if (typeof fn !== "function") return say({ accepted: false, reason: "unsupported" });
160698
- const pendingItemId = !opts?.system && opts?.clientMessageKey ? `oasis-user:${opts.clientMessageKey}` : void 0;
160745
+ const pendingItemId = !opts?.requireConsumptionReceipt && !opts?.system && opts?.clientMessageKey ? `oasis-user:${opts.clientMessageKey}` : void 0;
160699
160746
  const pendingAttachments = opts?.attachments?.map((a) => ({
160700
160747
  name: a.name,
160701
160748
  ...a.blobRef ? { blobRef: a.blobRef } : {},
@@ -160726,17 +160773,75 @@ var init_live_chat = __esm({
160726
160773
  payload: { role: "user" }
160727
160774
  });
160728
160775
  };
160776
+ let confirmed2 = false;
160777
+ let consumedItemId;
160778
+ let receiptPromise;
160779
+ const receiptKey = opts?.requireConsumptionReceipt && !opts.system ? opts.clientMessageKey : void 0;
160780
+ if (opts?.requireConsumptionReceipt && !opts.system && !receiptKey) {
160781
+ return say({ accepted: false, reason: "consumption-unsupported" });
160782
+ }
160783
+ if (receiptKey) {
160784
+ const existing = turn.inputReceipts.get(receiptKey);
160785
+ if (existing) {
160786
+ const consumed = await existing.result;
160787
+ await turn.items?.drain();
160788
+ return consumed && existing.itemId && turn.items?.ordFor(existing.itemId) !== void 0 ? { accepted: true } : { accepted: false, reason: "unconfirmed" };
160789
+ }
160790
+ let resolveReceipt;
160791
+ receiptPromise = new Promise((resolve10) => {
160792
+ resolveReceipt = resolve10;
160793
+ });
160794
+ turn.inputReceipts.set(receiptKey, {
160795
+ get itemId() {
160796
+ return consumedItemId;
160797
+ },
160798
+ result: receiptPromise,
160799
+ consume: () => {
160800
+ if (confirmed2 || turn.status !== "running") return;
160801
+ confirmed2 = true;
160802
+ consumedItemId = this.commitUserInput(turn, text5, opts);
160803
+ resolveReceipt(true);
160804
+ },
160805
+ abandon: () => {
160806
+ resolveReceipt(false);
160807
+ }
160808
+ });
160809
+ }
160810
+ const clearReceipt = () => {
160811
+ if (!receiptKey) return;
160812
+ turn.inputReceipts.get(receiptKey)?.abandon();
160813
+ turn.inputReceipts.delete(receiptKey);
160814
+ };
160729
160815
  let res;
160730
160816
  try {
160731
160817
  res = await fn.call(turn.handle, {
160732
160818
  text: text5,
160819
+ ...receiptKey ? { requireConsumptionReceipt: true } : {},
160733
160820
  ...opts?.clientMessageKey ? { clientMessageKey: opts.clientMessageKey } : {},
160734
160821
  ...opts?.files && Object.keys(opts.files).length ? { files: opts.files } : {},
160735
160822
  ...opts?.binaryFiles && Object.keys(opts.binaryFiles).length ? { binaryFiles: opts.binaryFiles } : {}
160736
160823
  });
160737
160824
  } catch (e) {
160825
+ if (!receiptKey) clearReceipt();
160738
160826
  failPending();
160739
- return say({ accepted: false, reason: "rejected" }, { err: String(e) });
160827
+ if (confirmed2) await turn.items?.drain();
160828
+ confirmed2 = confirmed2 && !!consumedItemId && turn.items?.ordFor(consumedItemId) !== void 0;
160829
+ return say({ accepted: confirmed2, ...!confirmed2 ? { reason: receiptKey ? "unconfirmed" : "rejected" } : {} }, { err: String(e) });
160830
+ }
160831
+ if (receiptPromise) {
160832
+ const notSent = ["unsupported", "consumption-unsupported", "session-closing", "no-live-turn", "recovering"];
160833
+ if (!confirmed2 && !res.accepted && notSent.includes(res.reason ?? "")) {
160834
+ clearReceipt();
160835
+ failPending();
160836
+ return say(res);
160837
+ }
160838
+ if (!await receiptPromise) {
160839
+ clearReceipt();
160840
+ failPending();
160841
+ return say({ accepted: false, reason: "unconfirmed" });
160842
+ }
160843
+ await turn.items?.drain();
160844
+ return say(!consumedItemId || turn.items?.ordFor(consumedItemId) === void 0 ? { accepted: false, reason: "unconfirmed" } : { accepted: true });
160740
160845
  }
160741
160846
  if (!res.accepted) {
160742
160847
  failPending();
@@ -160747,6 +160852,10 @@ var init_live_chat = __esm({
160747
160852
  failPending();
160748
160853
  return say({ accepted: false, reason: "turn-finished" }, { turnStatus: turn.status, replaced: this.turns.get(chatSessionId) !== turn });
160749
160854
  }
160855
+ this.commitUserInput(turn, text5, opts);
160856
+ return { accepted: true };
160857
+ }
160858
+ commitUserInput(turn, text5, opts) {
160750
160859
  const shown = opts?.displayText ?? text5;
160751
160860
  const userPart = {
160752
160861
  type: "user",
@@ -160755,7 +160864,7 @@ var init_live_chat = __esm({
160755
160864
  };
160756
160865
  const segmentEnd = (() => {
160757
160866
  try {
160758
- return turn.items?.closeSegmentBeforeUserInput() ?? null;
160867
+ return turn.items?.closeSegmentBeforeUserInput(!!opts?.requireConsumptionReceipt && !!turn.onUserInput) ?? null;
160759
160868
  } catch {
160760
160869
  return null;
160761
160870
  }
@@ -160771,8 +160880,9 @@ var init_live_chat = __esm({
160771
160880
  payload: { code: "segment_end", note: SEGMENT_END_NOTE }
160772
160881
  });
160773
160882
  }
160883
+ let userItemId;
160774
160884
  try {
160775
- turn.items?.recordUserInput(shown, opts?.attachments, opts?.clientKeys);
160885
+ userItemId = turn.items?.recordUserInput(shown, opts?.attachments, opts?.clientKeys);
160776
160886
  } catch {
160777
160887
  }
160778
160888
  const withSeq = { ...userPart, seq: ++turn.seq };
@@ -160806,7 +160916,7 @@ var init_live_chat = __esm({
160806
160916
  turn.onUserInput?.(shown, opts?.attachments);
160807
160917
  } catch {
160808
160918
  }
160809
- return { accepted: true };
160919
+ return userItemId;
160810
160920
  }
160811
160921
  /** 反查该会话当前驻留那一轮的 runId(无进行中/grace 轮,或该轮无 runId 时 undefined)。
160812
160922
  * 建单草案 submit 发生在这一轮内:用它给草案盖上「产出轮次」标记,供 friday 重进对话页把每版
@@ -160952,10 +161062,10 @@ var init_chat_attachment_files = __esm({
160952
161062
  });
160953
161063
 
160954
161064
  // ../server/src/domains/chat-sessions/append-now.ts
160955
- async function appendNowThroughQueue(chatSessionId, text5, deps, clientKey, attachments) {
161065
+ async function appendNowThroughQueue(chatSessionId, text5, deps, clientKey, attachments, retainLocallyOnFailure = false) {
160956
161066
  const newId = deps.newId ?? import_node_crypto20.randomUUID;
160957
161067
  const now = deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
160958
- await deps.store.enqueuePendingMessage({
161068
+ const submitted = await deps.store.enqueuePendingMessage({
160959
161069
  id: newId(),
160960
161070
  chatSessionId,
160961
161071
  text: text5,
@@ -160988,10 +161098,19 @@ async function appendNowThroughQueue(chatSessionId, text5, deps, clientKey, atta
160988
161098
  ...displayText !== merged ? { displayText } : {},
160989
161099
  ...attachmentRefs.length ? { attachments: attachmentRefs } : {}
160990
161100
  });
160991
- const DEFINITELY_NOT_DELIVERED = /* @__PURE__ */ new Set(["unsupported", "no-live-turn", "session-closing", "turn-finished", "recovering"]);
161101
+ const DEFINITELY_NOT_DELIVERED = /* @__PURE__ */ new Set(["unsupported", "no-live-turn", "session-closing", "turn-finished", "recovering", "consumption-unsupported"]);
160992
161102
  const nextState = res.accepted ? "delivered" : DEFINITELY_NOT_DELIVERED.has(res.reason ?? "") ? "queued" : "unconfirmed";
160993
- await deps.store.markPendingMessages?.(chatSessionId, ids2, nextState, res.reason).catch(() => void 0);
160994
- return { ...res, queued: true, delivered: merged, batch, ...prepared.paths.length ? { attachmentPaths: prepared.paths } : {} };
161103
+ const returnToClient = retainLocallyOnFailure && !!clientKey && nextState === "queued";
161104
+ if (returnToClient && deps.store.markPendingMessages) {
161105
+ await deps.store.markPendingMessages(chatSessionId, [submitted.id], "cancelled", "client-retained");
161106
+ await deps.store.markPendingMessages(chatSessionId, ids2.filter((id) => id !== submitted.id), nextState, res.reason);
161107
+ } else {
161108
+ const transition = deps.store.markPendingMessages?.(chatSessionId, ids2, nextState, res.reason);
161109
+ if (retainLocallyOnFailure) await transition;
161110
+ else await transition?.catch(() => void 0);
161111
+ if (returnToClient) await deps.store.removePendingMessage(chatSessionId, submitted.id);
161112
+ }
161113
+ return { ...res, queued: !returnToClient, delivered: merged, batch, ...prepared.paths.length ? { attachmentPaths: prepared.paths } : {} };
160995
161114
  }
160996
161115
  var import_node_crypto20;
160997
161116
  var init_append_now = __esm({
@@ -204053,7 +204172,7 @@ async function startOasisServer(opts) {
204053
204172
  }
204054
204173
  return void 0;
204055
204174
  }
204056
- const appendNowThroughQueue2 = async (sid, text5, clientKey, attachments, perCompanyStore) => {
204175
+ const appendNowThroughQueue2 = async (sid, text5, clientKey, attachments, perCompanyStore, retainLocallyOnFailure = false) => {
204057
204176
  if (perCompanyStore === null) {
204058
204177
  throw new Error(
204059
204178
  `appendNowThroughQueue: per-company chat store unresolved (sid=${sid})`
@@ -204067,7 +204186,7 @@ async function startOasisServer(opts) {
204067
204186
  store,
204068
204187
  // ADR-0166 D9.1:附件与正文**同一次**送出——拆开就等于让 agent 拿着「见附件」而
204069
204188
  // 附件不存在的描述去干活。`files` 空时这次调用与从前逐字相同。
204070
- deliver: (merged, clientMessageKey, extra) => liveChat.append(sid, merged, { clientMessageKey, ...extra }),
204189
+ deliver: (merged, clientMessageKey, extra) => liveChat.append(sid, merged, { clientMessageKey, ...extra, requireConsumptionReceipt: true }),
204071
204190
  // **先查资产柜再查事实柜**——chat 附件是 `uploadChatAttachment` 存进**资产柜**的
204072
204191
  // (serve 那侧 `assets.put`),两个柜是**物理隔离**的(「资产柜与事实柜分开」)。
204073
204192
  // 只查 `opts.blobs` 必然取不到,于是每张图都被报成「附件没能取回来」。
@@ -204082,8 +204201,8 @@ async function startOasisServer(opts) {
204082
204201
  }
204083
204202
  throw new Error(`blob ${blobRef} \u4E24\u4E2A\u67DC\u91CC\u90FD\u6CA1\u6709`);
204084
204203
  }
204085
- }, clientKey, attachments);
204086
- return { accepted: r.accepted, ...r.reason ? { reason: r.reason } : {}, queued: true };
204204
+ }, clientKey, attachments, retainLocallyOnFailure);
204205
+ return { accepted: r.accepted, ...r.reason ? { reason: r.reason } : {}, queued: r.queued };
204087
204206
  };
204088
204207
  const GRAPH_EDIT_SUMMARY_MAX = 60;
204089
204208
  const truncateSummary = (s2) => {
@@ -206240,9 +206359,11 @@ async function startOasisServer(opts) {
206240
206359
  let text5 = "";
206241
206360
  let clientKey;
206242
206361
  let insertAttachments = [];
206362
+ let retainLocallyOnFailure = false;
206243
206363
  try {
206244
206364
  const b2 = JSON.parse(rawBody);
206245
206365
  text5 = String(b2.text ?? "").trim();
206366
+ retainLocallyOnFailure = b2.retainLocallyOnFailure === true;
206246
206367
  if (typeof b2.clientKey === "string" && b2.clientKey) clientKey = b2.clientKey;
206247
206368
  insertAttachments = (Array.isArray(b2.attachments) ? b2.attachments : []).filter((a) => !!a && typeof a === "object" && typeof a["name"] === "string" && (typeof a["text"] === "string" || typeof a["blobRef"] === "string")).map((a) => ({
206248
206369
  name: a["name"],
@@ -206256,7 +206377,7 @@ async function startOasisServer(opts) {
206256
206377
  res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: "EMPTY_TEXT" }));
206257
206378
  return;
206258
206379
  }
206259
- const result = await appendNowThroughQueue2(sid, text5, clientKey, insertAttachments, perCompanyStore);
206380
+ const result = await appendNowThroughQueue2(sid, text5, clientKey, insertAttachments, perCompanyStore, retainLocallyOnFailure);
206260
206381
  res.writeHead(result.accepted ? 200 : 409, { "content-type": "application/json" }).end(JSON.stringify(result));
206261
206382
  return;
206262
206383
  }
@@ -218446,6 +218567,10 @@ var init_daemon_hub = __esm({
218446
218567
  this.onHandshakeRejected?.(rejection);
218447
218568
  cb(false, code2, message);
218448
218569
  }
218570
+ /** Ask the running session: an upgrade leaves existing sessions alive. */
218571
+ supportsInputConsumptionReceipts(dispatchId) {
218572
+ return this.hasSession(dispatchId) && this.sessionMeta.get(dispatchId)?.supportsInputConsumptionReceipts === true;
218573
+ }
218449
218574
  dispatch(daemonId, msg) {
218450
218575
  const daemon = this.daemons.get(daemonId);
218451
218576
  if (!daemon) return false;
@@ -218515,6 +218640,7 @@ var init_daemon_hub = __esm({
218515
218640
  nodeId,
218516
218641
  sessionId: msg.sessionId,
218517
218642
  connectedAt: Date.now(),
218643
+ supportsInputConsumptionReceipts: msg.supportsInputConsumptionReceipts === true,
218518
218644
  ...msg.jobArtifactId ? { jobArtifactId: msg.jobArtifactId } : {}
218519
218645
  });
218520
218646
  for (const l of this.sessionConnectListeners) l({ dispatchId, sessionId: msg.sessionId, nodeId });
@@ -223300,6 +223426,16 @@ var init_assistant_tier_dispatch = __esm({
223300
223426
  // ../server/src/domains/projects/routes.ts
223301
223427
  function projectsDomain(opts) {
223302
223428
  const svc = async (req) => opts.resolveService ? opts.resolveService(req.auth.companyId) : opts.service;
223429
+ const requireProjectManagePermission = async (req, projectId2, action) => {
223430
+ if (!opts.resolveMemberRole) return;
223431
+ if (isUncategorizedProjectId(projectId2)) return;
223432
+ const project = await (await svc(req)).getProject(projectId2);
223433
+ if (!project) throw new ApiError(404, "NOT_FOUND", `\u6CA1\u6709\u8FD9\u4E2A\u9879\u76EE\uFF1A${projectId2}`);
223434
+ if (project.createdBy && project.createdBy.id === req.auth.actor) return;
223435
+ const role = await opts.resolveMemberRole(req.auth.companyId, req.auth.actor);
223436
+ if (role === "owner" || role === "admin") return;
223437
+ throw new ApiError(403, action.code, `\u4F60\u6CA1\u6709${action.verb}\u8BE5\u9879\u76EE\u7684\u6743\u9650\uFF0C\u8BF7\u8054\u7CFB\u7EC4\u7EC7\u7BA1\u7406\u5458`);
223438
+ };
223303
223439
  return (router) => {
223304
223440
  router.post("/api/projects", async (req) => {
223305
223441
  const body2 = req.body;
@@ -223341,6 +223477,7 @@ function projectsDomain(opts) {
223341
223477
  return { status: 200, body: toApiProject(project) };
223342
223478
  });
223343
223479
  router.patch("/api/projects/:id", async (req) => {
223480
+ await requireProjectManagePermission(req, req.params.id, { verb: "\u7F16\u8F91", code: "PROJECT_EDIT_FORBIDDEN" });
223344
223481
  const body2 = req.body ?? {};
223345
223482
  for (const immutable of ["id", "project_id", "projectId", "createdBy", "created_by", "ownerId", "owner_id"]) {
223346
223483
  if (Object.prototype.hasOwnProperty.call(body2, immutable)) {
@@ -223360,6 +223497,7 @@ function projectsDomain(opts) {
223360
223497
  }
223361
223498
  });
223362
223499
  router.delete("/api/projects/:id", async (req) => {
223500
+ await requireProjectManagePermission(req, req.params.id, { verb: "\u5220\u9664", code: "PROJECT_DELETE_FORBIDDEN" });
223363
223501
  try {
223364
223502
  const result = await (await svc(req)).deleteProject(req.params.id);
223365
223503
  return {
@@ -228641,7 +228779,8 @@ function createProjectsDomain(opts) {
228641
228779
  register: projectsDomain({
228642
228780
  service,
228643
228781
  ...opts.resolveCompany ? { resolveService } : {},
228644
- ...opts.defaultBranchResolver ? { defaultBranchResolver: opts.defaultBranchResolver } : {}
228782
+ ...opts.defaultBranchResolver ? { defaultBranchResolver: opts.defaultBranchResolver } : {},
228783
+ ...opts.resolveMemberRole ? { resolveMemberRole: opts.resolveMemberRole } : {}
228645
228784
  }),
228646
228785
  ...opts.resolveCompany ? { resolveService } : {}
228647
228786
  };
@@ -239294,6 +239433,9 @@ function routeToSession(hub, nodeId, dispatchId, msg) {
239294
239433
  function remoteAppendInput(hub, nodeId, dispatchId, entry, input, timeoutMs) {
239295
239434
  const unknownIfInterrupting = input.interrupt ? { interrupt: "unknown" } : {};
239296
239435
  if (entry.exited) return Promise.resolve({ accepted: false, reason: "session-closing", ...unknownIfInterrupting });
239436
+ if (input.requireConsumptionReceipt && !hub.supportsInputConsumptionReceipts?.(dispatchId)) {
239437
+ return Promise.resolve({ accepted: false, reason: "consumption-unsupported" });
239438
+ }
239297
239439
  const requestId = (0, import_node_crypto65.randomUUID)();
239298
239440
  const sent = routeToSession(hub, nodeId, dispatchId, { type: "append_input", dispatchId, input, requestId });
239299
239441
  if (!sent) return Promise.resolve({ accepted: false, reason: "session-closing", ...unknownIfInterrupting });
@@ -239301,10 +239443,10 @@ function remoteAppendInput(hub, nodeId, dispatchId, entry, input, timeoutMs) {
239301
239443
  const timer = setTimeout(() => {
239302
239444
  const i = entry.appendWaiters.findIndex((w2) => w2.timer === timer);
239303
239445
  if (i >= 0) entry.appendWaiters.splice(i, 1);
239304
- resolve10({ accepted: false, reason: "unsupported", ...unknownIfInterrupting });
239446
+ resolve10({ accepted: false, reason: input.requireConsumptionReceipt ? "unconfirmed" : "unsupported", ...unknownIfInterrupting });
239305
239447
  }, timeoutMs);
239306
239448
  timer.unref?.();
239307
- entry.appendWaiters.push({ requestId, resolve: resolve10, timer });
239449
+ entry.appendWaiters.push({ requestId, requireConsumptionReceipt: input.requireConsumptionReceipt, resolve: resolve10, timer });
239308
239450
  });
239309
239451
  }
239310
239452
  var import_node_crypto65, STASH_TTL_MS, STASH_MAX_FRAMES_PER_ID, STASH_MAX_IDS, DaemonHubAdapter, BindingRouterAdapter, WorkdirBridge;
@@ -239451,7 +239593,7 @@ var init_daemon_adapter = __esm({
239451
239593
  entry.exited = info;
239452
239594
  for (const w2 of entry.appendWaiters.splice(0)) {
239453
239595
  clearTimeout(w2.timer);
239454
- w2.resolve({ accepted: false, reason: "session-closing" });
239596
+ w2.resolve({ accepted: false, reason: w2.requireConsumptionReceipt ? "unconfirmed" : "session-closing" });
239455
239597
  }
239456
239598
  if (info.reason === "server-unreachable") this.health?.recordUnreachable(entry.nodeId, info.reason);
239457
239599
  else this.health?.recordReachable(entry.nodeId);
@@ -253339,6 +253481,17 @@ var init_cutover_aware_dispatches = __esm({
253339
253481
  if (row.state === "frozen") throw new BundleFrozenError("dispatch");
253340
253482
  return this.o.cutover.readsCompanySchema(row) ? this.o.owned : this.o.legacy;
253341
253483
  }
253484
+ /**
253485
+ * 这家公司的派发台账当前**物理落在哪**:切走了就是它自己的引擎 schema,没切走就是那个
253486
+ * 所有未切公司共用的 legacy 台账。
253487
+ *
253488
+ * 给 {@link FanOutDispatchLedger} 去重用。判据**不在调用方重算**——就是 {@link pick}
253489
+ * 那一条(`cutover.get` + `readsCompanySchema`)。与 `CutoverAwareTraceStore.storageKey` 同构。
253490
+ */
253491
+ async storageKey() {
253492
+ const row = await this.o.cutover.get(this.o.companyId, "dispatch");
253493
+ return this.o.cutover.readsCompanySchema(row) ? `owned:${this.o.companyId}` : "legacy";
253494
+ }
253342
253495
  async insertOpen(rec) {
253343
253496
  return (await this.pickWritable()).insertOpen(rec);
253344
253497
  }
@@ -253374,11 +253527,48 @@ var init_cutover_aware_dispatches = __esm({
253374
253527
  constructor(o) {
253375
253528
  this.o = o;
253376
253529
  }
253377
- async each(op, fn) {
253530
+ /**
253531
+ * 本次要作用的物理落点清单——**同一个台账只出现一次**。
253532
+ *
253533
+ * 为什么必须去重:`dispatch` 是按 (公司, bundle) 逐个切的,没切走的公司**全部共用同一个
253534
+ * legacy 台账**(读写同一张 `public.dispatches`)。此前 `each` 直接按公司逐家来,于是同一张
253535
+ * 表被访问 N 次(N = 未切走的公司数):
253536
+ *
253537
+ * - **纯读直接出重复行**(`listOpen` / `listByWorkorder` / `findOpenByTarget`)。
253538
+ * 2026-09-11 v2 生产实证(`ws:wo-9f4652cd`):35 家公司、其中 33 家 dispatch 未切走,
253539
+ * `listByWorkorder` 返回 33 份同一条派发,动态卡「执行轨迹」抽屉里同一轮 attempt
253540
+ * 就出现 33 次(`attempts` 由它喂,见 server 的 `activity-trace.ts`)。
253541
+ * - 写侧**没有被放大**,但原因是运气好而不是设计:`markStarted` / `close` 是同值 UPDATE;
253542
+ * `closeStaleNeverStarted` / `closeOpenByTarget` 的 WHERE 带 `ended_at IS NULL`,
253543
+ * 第一遍改完第二遍就匹配不上,返回 0,`reduce` 出来仍是真实值。
253544
+ * - 无论读写,同一条 SQL 都实打实打了 N 遍。
253545
+ *
253546
+ * 去重键由台账自己报,不在这里按 cutover 状态重算(见 `storageKey` 的注释)。也不能按
253547
+ * `ledgerFor` 返回的对象做身份去重:每家各有一个 `CutoverAwareDispatchStore` 包装实例,
253548
+ * N 个不同对象包着同一个 legacy 台账。
253549
+ */
253550
+ async targets(op) {
253378
253551
  const out = [];
253552
+ const seen = /* @__PURE__ */ new Set();
253379
253553
  for (const cid of await this.o.listCompanies()) {
253380
253554
  try {
253381
- out.push(await fn(await this.o.ledgerFor(cid)));
253555
+ if (this.o.storageKeyFor) {
253556
+ const key = await this.o.storageKeyFor(cid);
253557
+ if (seen.has(key)) continue;
253558
+ seen.add(key);
253559
+ }
253560
+ out.push({ cid, ledger: await this.o.ledgerFor(cid) });
253561
+ } catch (err) {
253562
+ this.o.onError?.(cid, op, err);
253563
+ }
253564
+ }
253565
+ return out;
253566
+ }
253567
+ async each(op, fn) {
253568
+ const out = [];
253569
+ for (const { cid, ledger } of await this.targets(op)) {
253570
+ try {
253571
+ out.push(await fn(ledger));
253382
253572
  } catch (err) {
253383
253573
  this.o.onError?.(cid, op, err);
253384
253574
  }
@@ -259595,6 +259785,14 @@ async function startServe(opts) {
259595
259785
  actorDirectory: registryStore,
259596
259786
  // 与 collab 同源;闭包避开下方 listCompanyMembers 声明的 TDZ,实际请求时才读取。
259597
259787
  listMembers: (companyId) => listCompanyMembers(companyId),
259788
+ // 改/删项目的门禁判据之一(另一半是项目创建人)——见 domains/projects/routes.ts 的
259789
+ // `requireProjectManagePermission`。同样用闭包避开 listCompanyMembers 的 TDZ。
259790
+ // 成员表按 company_id 有索引、量级几十行,与 `listMembers` 同一次读的成本量级;
259791
+ // 只在 PATCH/DELETE 这两个低频写端点上调,不在任何读热路径上。
259792
+ resolveMemberRole: async (companyId, actorId) => {
259793
+ const members = await listCompanyMembers(companyId);
259794
+ return members.find((m2) => m2.actorId === actorId)?.role;
259795
+ },
259598
259796
  resolveActorContext: (actorId) => buildActorContext(actors.service, actorId),
259599
259797
  defaultBranchResolver,
259600
259798
  // 展示:GET content-locations 给 git 仓补 resolvedDefaultBranch
@@ -260611,6 +260809,15 @@ async function startServe(opts) {
260611
260809
  // 只扫 active 的公司:停用公司的台账不该再被兜底扫改写(与本文件其它按公司扇出的地方同口径)。
260612
260810
  listCompanies: async () => (await controlPlaneStore.listCompanies()).filter((c) => c.status === "active").map((c) => c.id),
260613
260811
  ledgerFor: (cid) => dispatchLedgerFor(cid),
260812
+ /**
260813
+ * 去重键:`dispatch` 未切走的公司全部共用同一张 legacy 台账,扇出只该作用一次。
260814
+ * 由台账自己报落点,这里不按 cutover 状态重算(见 FanOutDispatchLedger.targets 的注释)。
260815
+ * 非 PG / 非 cutover-aware 的路径没有这个方法 ⇒ 退回按公司逐家来,行为不变。
260816
+ */
260817
+ storageKeyFor: async (cid) => {
260818
+ const l = await dispatchLedgerFor(cid);
260819
+ return typeof l.storageKey === "function" ? l.storageKey() : cid;
260820
+ },
260614
260821
  onError: (cid, op, err) => console.warn(`[dispatch-ledger] ${cid} \u7684 ${op} \u5931\u8D25\uFF08\u5176\u4F59\u516C\u53F8\u7EE7\u7EED\uFF09\uFF1A${String(err)}`)
260615
260822
  }) : dispatchLedger;
260616
260823
  const companyRegistryFor = async (companyId) => pgPool ? (await openCompanyPlane(companyId)).overlay : scopedRegistryFor(companyId);
@@ -265262,7 +265469,8 @@ var SessionProcessState = class {
265262
265469
  // 只报 dispatchId 的话 serve 重启后它认不出这是条活会话,会当成野进程 evict 掉。
265263
265470
  ...this.spec.job.artifactId ? { jobArtifactId: this.spec.job.artifactId } : {},
265264
265471
  // ADR 0300 D2:自报支持帧号;新服务端据此回带 ackSeq 的 hello-ack,届时才把 serverSupportsDedup 置真。
265265
- supportsFrameSeq: true
265472
+ supportsFrameSeq: true,
265473
+ supportsInputConsumptionReceipts: true
265266
265474
  });
265267
265475
  const toResend = this.serverSupportsDedup ? this.outbox : this.outbox.filter(isKeyFrame);
265268
265476
  for (const m2 of toResend) this.rawSend(m2);
@@ -272184,7 +272392,7 @@ function shimScript() {
272184
272392
  }
272185
272393
 
272186
272394
  // src/index.ts
272187
- var PKG_VERSION = true ? "2.2.18" : "dev";
272395
+ var PKG_VERSION = true ? "2.2.19" : "dev";
272188
272396
  var LOCAL_BIN = localBin();
272189
272397
  var NPM_PREFIX = npmPrefix();
272190
272398
  var INSTANCE = DEFAULT_INSTANCE;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oasis_test_v2",
3
- "version": "2.2.18",
3
+ "version": "2.2.19",
4
4
  "description": "Oasis node daemon + CLI — background daemon, auto-start, full server CLI",
5
5
  "bin": {
6
6
  "oasis": "./dist/index.js"
@@ -26,6 +26,6 @@
26
26
  "node": ">=20"
27
27
  },
28
28
  "oasisRelease": {
29
- "sourceHead": "dacd0d5f49191a0ba05c308ee96fef59efac071d"
29
+ "sourceHead": "2b179b35f141bdd0e3785e0cf069d22b76fc8a37"
30
30
  }
31
31
  }