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.
- package/dist/index.js +241 -33
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
160994
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
29
|
+
"sourceHead": "2b179b35f141bdd0e3785e0cf069d22b76fc8a37"
|
|
30
30
|
}
|
|
31
31
|
}
|