dsh-decision-layer 0.6.0 → 0.6.1
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/README.en.md +2 -2
- package/README.md +2 -2
- package/lib/client.js +49 -21
- package/package.json +1 -1
- package/service/dsh.mjs +3 -2
- package/service/log-analyze.mjs +24 -0
- package/service/manager-api.mjs +16 -1
package/README.en.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
`dsh-decision-layer` adds structured, explicitly scoped decisions to the DeepSeek Harness (DSH) agent loop, backed by a pluggable adjudication backend. Dangerous-call gating, output self-check, tool narrowing, and loop prevention hook into the agent loop as automatic interventions.
|
|
6
6
|
|
|
7
|
-
> **0.6.
|
|
7
|
+
> **0.6.1 is a preview release.** The plugin works, but how much real value the adjudication interventions add is still being explored — every threshold is an unvalidated heuristic default. Treat it as something to observe, not a proven guarantee of impact. See [ROADMAP.md](<ROADMAP.md>) for scope and progress.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -50,4 +50,4 @@ Restart `dsh web` and refresh after installing or updating: the host and client
|
|
|
50
50
|
|
|
51
51
|
## Safety boundary and development
|
|
52
52
|
|
|
53
|
-
Automatic intervention only subtracts: the gate only denies, narrowing only removes tools, and neither grants new permissions. The gate defers to native DSH approval on backend failure rather than allowing; self-check, narrowing, and loop-guard errors skip intervention. The redacted summary sent to the backend carries only tool name and command/path with secret redaction — never file content. Node.js `>=20.11`; `npm test` and `npm run build:client` run local checks. Rebuild and commit `lib/client.js` with any client change. 0.6.
|
|
53
|
+
Automatic intervention only subtracts: the gate only denies, narrowing only removes tools, and neither grants new permissions. The gate defers to native DSH approval on backend failure rather than allowing; self-check, narrowing, and loop-guard errors skip intervention. The redacted summary sent to the backend carries only tool name and command/path with secret redaction — never file content. Node.js `>=20.11`; `npm test` and `npm run build:client` run local checks. Rebuild and commit `lib/client.js` with any client change. 0.6.1 is a preview: the interventions work, but their real value and thresholds are still being explored — feedback on misjudgments and feel observed in the decision log is welcome.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
DeepSeek Harness(DSH)的结构化裁决插件。它把有明确判断标准的问题交给同协议的裁决后端,供 agent 在需要时使用,并在 agent loop 上接入危险门控、产出自检、工具收窄和防循环等自动介入。
|
|
6
6
|
|
|
7
|
-
> **0.6.
|
|
7
|
+
> **0.6.1 是体验版。** 插件功能已可用,但裁决介入能带来多少实际价值仍在探索中——各决策点的阈值都是未经验证的启发式默认值,建议先以观察心态体验,别当成经过验证的效果保证。功能与后续进度以 [ROADMAP.md](<ROADMAP.md>) 为准。
|
|
8
8
|
|
|
9
9
|
## 安装
|
|
10
10
|
|
|
@@ -54,4 +54,4 @@ dsh plugin --profile web add file:/绝对路径/dsh-decision-layer
|
|
|
54
54
|
|
|
55
55
|
## 开发
|
|
56
56
|
|
|
57
|
-
服务端使用 Node.js `>=20.11`(`.mjs`,无需构建);客户端用 TypeScript + esbuild 构建。`npm test` 跑本地测试,`npm run build:client` 生成 `lib/client.js`。改动客户端后需重建并连同 `lib/client.js` 一起提交,否则 GitHub 安装拿到的是旧 bundle。0.6.
|
|
57
|
+
服务端使用 Node.js `>=20.11`(`.mjs`,无需构建);客户端用 TypeScript + esbuild 构建。`npm test` 跑本地测试,`npm run build:client` 生成 `lib/client.js`。改动客户端后需重建并连同 `lib/client.js` 一起提交,否则 GitHub 安装拿到的是旧 bundle。0.6.1 为体验版:介入能力已可用,但实际价值与阈值仍在探索,欢迎反馈决策日志观察到的误判与体感。
|
package/lib/client.js
CHANGED
|
@@ -183,7 +183,10 @@ var dictionaries = {
|
|
|
183
183
|
analysisAccuracy: "\u5224\u65AD\u51C6\u786E\u5EA6",
|
|
184
184
|
analysisAccuracyEmpty: "\u5C1A\u672A\u6807\u6CE8\u4EFB\u4F55\u51B3\u7B56\u3002\u5728\u4E0B\u65B9\u51B3\u7B56\u5217\u8868\u9010\u6761\u6807\u6CE8\u300C\u5224\u5BF9 / \u5224\u9519 / \u8BF4\u4E0D\u51C6\u300D\u540E\uFF0C\u8FD9\u91CC\u7ED9\u51FA\u6C47\u603B\u3002",
|
|
185
185
|
analysisLogNote: "\u6807\u6CE8\u5728\u6B64\u4E0E\u4ECB\u5165\u9762\u677F\u540C\u6E90\uFF1A\u4EFB\u4E00\u5904\u6807\u6CE8\u90FD\u5199\u5165\u540C\u4E00\u4EFD\u65E5\u5FD7\u3001\u6309\u51B3\u7B56 id \u5173\u8054\u3001\u53D6\u6700\u65B0\u3002",
|
|
186
|
-
analysisLogTruncated: "\u672C\u4F1A\u8BDD\u6682\u65E0\u53EF\u5C55\u793A\u7684\u51B3\u7B56\u8BB0\u5F55\
|
|
186
|
+
analysisLogTruncated: "\u672C\u4F1A\u8BDD\u6682\u65E0\u53EF\u5C55\u793A\u7684\u51B3\u7B56\u8BB0\u5F55\u3002",
|
|
187
|
+
pagerPrev: "\u4E0A\u4E00\u9875",
|
|
188
|
+
pagerNext: "\u4E0B\u4E00\u9875",
|
|
189
|
+
pagerStatus: "\u7B2C {page}/{pages} \u9875 \xB7 \u5171 {total} \u6761"
|
|
187
190
|
},
|
|
188
191
|
en: {
|
|
189
192
|
summary: "A danger-call gate, output self-check, and loop guard driven by a pluggable adjudication model.",
|
|
@@ -335,7 +338,10 @@ var dictionaries = {
|
|
|
335
338
|
analysisAccuracy: "Decision accuracy",
|
|
336
339
|
analysisAccuracyEmpty: "No decisions rated yet. Rate rows below as correct / wrong / unsure and a summary appears here.",
|
|
337
340
|
analysisLogNote: "Ratings here share one source with the intervention panel: a rating on either surface writes to the same log, keyed by decision id, latest wins.",
|
|
338
|
-
analysisLogTruncated: "No decision rows to show for this session
|
|
341
|
+
analysisLogTruncated: "No decision rows to show for this session.",
|
|
342
|
+
pagerPrev: "Prev",
|
|
343
|
+
pagerNext: "Next",
|
|
344
|
+
pagerStatus: "Page {page}/{pages} \xB7 {total} total"
|
|
339
345
|
}
|
|
340
346
|
};
|
|
341
347
|
|
|
@@ -931,9 +937,11 @@ function SessionPanel({ sessionId, t }) {
|
|
|
931
937
|
// src/client/analysis-view.tsx
|
|
932
938
|
var import_react3 = require("react");
|
|
933
939
|
var import_jsx_runtime4 = require("react/jsx-runtime");
|
|
940
|
+
var LOG_PAGE_SIZE = 100;
|
|
934
941
|
function AnalysisView({ sessionId, t }) {
|
|
935
942
|
const [analysis, setAnalysis] = (0, import_react3.useState)(null);
|
|
936
|
-
const [
|
|
943
|
+
const [rows, setRows] = (0, import_react3.useState)(null);
|
|
944
|
+
const [logPage, setLogPage] = (0, import_react3.useState)(1);
|
|
937
945
|
const [loaded, setLoaded] = (0, import_react3.useState)(false);
|
|
938
946
|
const [message, setMessage] = (0, import_react3.useState)("");
|
|
939
947
|
const { ratings, seed, annotate } = useAnnotations(sessionId, () => setMessage(t("annotateFailed")));
|
|
@@ -941,23 +949,31 @@ function AnalysisView({ sessionId, t }) {
|
|
|
941
949
|
let alive = true;
|
|
942
950
|
setMessage("");
|
|
943
951
|
setLoaded(false);
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
})
|
|
954
|
-
]).finally(() => {
|
|
952
|
+
setLogPage(1);
|
|
953
|
+
setRows(null);
|
|
954
|
+
void request(`logs?sessionId=${encodeURIComponent(sessionId)}`).then((value) => {
|
|
955
|
+
if (alive) {
|
|
956
|
+
setAnalysis(value);
|
|
957
|
+
seed(value);
|
|
958
|
+
}
|
|
959
|
+
}).catch(() => {
|
|
960
|
+
}).finally(() => {
|
|
955
961
|
if (alive) setLoaded(true);
|
|
956
962
|
});
|
|
957
963
|
return () => {
|
|
958
964
|
alive = false;
|
|
959
965
|
};
|
|
960
966
|
}, [sessionId, seed]);
|
|
967
|
+
(0, import_react3.useEffect)(() => {
|
|
968
|
+
let alive = true;
|
|
969
|
+
void request(`logrows?sessionId=${encodeURIComponent(sessionId)}&page=${logPage}&pageSize=${LOG_PAGE_SIZE}`).then((value) => {
|
|
970
|
+
if (alive) setRows(value);
|
|
971
|
+
}).catch(() => {
|
|
972
|
+
});
|
|
973
|
+
return () => {
|
|
974
|
+
alive = false;
|
|
975
|
+
};
|
|
976
|
+
}, [sessionId, logPage]);
|
|
961
977
|
const profile = analysis?.profile;
|
|
962
978
|
const backtest = analysis?.trendBacktest;
|
|
963
979
|
const annotations = analysis?.annotations;
|
|
@@ -1021,8 +1037,13 @@ function AnalysisView({ sessionId, t }) {
|
|
|
1021
1037
|
] }),
|
|
1022
1038
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("section", { className: "decision-analysis-section", children: [
|
|
1023
1039
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h3", { children: t("log") }),
|
|
1024
|
-
|
|
1025
|
-
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(DecisionLogList, { entries:
|
|
1040
|
+
!rows || rows.total === 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-empty", role: "status", children: t("analysisLogTruncated") }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
|
|
1041
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(DecisionLogList, { entries: rows.entries, ratings, onRate: (id, rating) => void annotate(id, rating), t, newestFirst: false }),
|
|
1042
|
+
rows.pages > 1 && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("nav", { className: "decision-pager", "aria-label": t("log"), children: [
|
|
1043
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", disabled: rows.page <= 1, onClick: () => setLogPage((page) => Math.max(1, page - 1)), children: t("pagerPrev") }),
|
|
1044
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "decision-pager-status", children: t("pagerStatus", { page: rows.page, pages: rows.pages, total: rows.total }) }),
|
|
1045
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", disabled: rows.page >= rows.pages, onClick: () => setLogPage((page) => page + 1), children: t("pagerNext") })
|
|
1046
|
+
] }),
|
|
1026
1047
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-muted decision-analysis-note", children: t("analysisLogNote") })
|
|
1027
1048
|
] })
|
|
1028
1049
|
] })
|
|
@@ -1566,13 +1587,13 @@ var css = `
|
|
|
1566
1587
|
.decision-dot-deny{background:#c0392b}
|
|
1567
1588
|
.decision-card-foot{display:flex;justify-content:space-between;gap:1rem;margin-top:.7rem;padding-top:.6rem;border-top:1px solid var(--dsw-alias-border-l2,#eef1f3);font-size:.76rem;color:var(--dsw-alias-label-secondary,#59636e)}
|
|
1568
1589
|
.decision-card-foot b{color:var(--dsw-alias-label-primary,#202124);font-variant-numeric:tabular-nums;margin-left:.3rem}
|
|
1569
|
-
.decision-card-stats{display:
|
|
1570
|
-
.decision-stat{
|
|
1571
|
-
.decision-stat-num{font-size:1.35rem;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#202124)}
|
|
1590
|
+
.decision-card-stats{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:.6rem;align-items:stretch}
|
|
1591
|
+
.decision-stat{min-width:0;display:flex;flex-direction:column;align-items:center;justify-content:space-between;gap:.2rem;padding:.5rem .3rem;border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f6f8fa)}
|
|
1592
|
+
.decision-stat-num{font-size:1.35rem;font-weight:700;line-height:1;white-space:nowrap;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#202124)}
|
|
1572
1593
|
.decision-stat-pct{font-size:.74rem;font-weight:600;line-height:1;color:var(--dsw-alias-label-tertiary,#77818b);font-variant-numeric:tabular-nums}
|
|
1573
1594
|
.decision-stat-warn{color:#c98a1e}
|
|
1574
1595
|
.decision-stat-muted{color:var(--dsw-alias-label-tertiary,#9aa5b1)}
|
|
1575
|
-
.decision-stat-label{font-size:.72rem;color:var(--dsw-alias-label-secondary,#59636e)}
|
|
1596
|
+
.decision-stat-label{font-size:.72rem;line-height:1.25;text-align:center;color:var(--dsw-alias-label-secondary,#59636e)}
|
|
1576
1597
|
.decision-log{list-style:none;margin:0;padding:0;display:grid;gap:.5rem;max-height:13rem;overflow:auto}
|
|
1577
1598
|
.decision-log-item{display:flex;align-items:baseline;gap:.6rem;font-size:.8rem;padding-left:.6rem;border-left:2px solid var(--dsw-alias-border-l2,#dce1e6);line-height:1.5}
|
|
1578
1599
|
.decision-log-item.decision-log-deny{border-left-color:#c0392b}
|
|
@@ -1693,9 +1714,16 @@ var css = `
|
|
|
1693
1714
|
.decision-analysis-section{border-top:1px solid var(--dsw-alias-border-l2,#eceff1);margin-top:1.1rem;padding-top:1rem}
|
|
1694
1715
|
.decision-analysis-section>h3{font-size:.92rem;margin:0 0 .6rem}
|
|
1695
1716
|
.decision-analysis-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(13rem,1fr));gap:.7rem}
|
|
1696
|
-
.decision-analysis-cards .decision-card-stats{flex-wrap:wrap}
|
|
1697
1717
|
.decision-analysis-trend{max-width:26rem}
|
|
1698
1718
|
.decision-analysis-note{margin:.5rem 0 0}
|
|
1719
|
+
/* The analysis tab's decision log shares the page scroll (no inner scrollbar);
|
|
1720
|
+
the modal panel's .decision-log keeps its own capped scroll via the base rule. */
|
|
1721
|
+
.decision-analysis .decision-log{max-height:none;overflow:visible}
|
|
1722
|
+
.decision-pager{display:flex;align-items:center;justify-content:center;gap:.8rem;margin-top:.8rem}
|
|
1723
|
+
.decision-pager button{border:1px solid var(--dsw-alias-border-l2,#ccd3da);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#59636e);padding:.3rem .85rem;font:inherit;font-size:.8rem;cursor:pointer}
|
|
1724
|
+
.decision-pager button:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1,#f2f4f6);color:var(--dsw-alias-label-primary,#202124)}
|
|
1725
|
+
.decision-pager button:disabled{opacity:.45;cursor:default}
|
|
1726
|
+
.decision-pager-status{font-size:.8rem;color:var(--dsw-alias-label-secondary,#59636e);font-variant-numeric:tabular-nums}
|
|
1699
1727
|
@media (prefers-color-scheme:dark){
|
|
1700
1728
|
.decision-analysis{color:var(--dsw-alias-label-primary,#e6eaee)}
|
|
1701
1729
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-decision-layer",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, and loop guard backed by a pluggable adjudication model.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
package/service/dsh.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { createTaskCompletion } from './task-completion.mjs';
|
|
|
11
11
|
import { createLoopGuard } from './loop-guard.mjs';
|
|
12
12
|
import { createLogStore } from './log-store.mjs';
|
|
13
13
|
import { createStateStore } from './state-store.mjs';
|
|
14
|
-
import { analyzeSessionLogs, reconstructSessionSeed } from './log-analyze.mjs';
|
|
14
|
+
import { analyzeSessionLogs, reconstructSessionSeed, listSessionDecisions } from './log-analyze.mjs';
|
|
15
15
|
|
|
16
16
|
export const name = 'dsh-decision-layer';
|
|
17
17
|
export const inject = ['tools', 'skills'];
|
|
@@ -261,7 +261,8 @@ export async function apply(ctx, options = {}) {
|
|
|
261
261
|
port,
|
|
262
262
|
trendConfig: (await resolveConfig({ path })).checkSettings,
|
|
263
263
|
});
|
|
264
|
-
const
|
|
264
|
+
const listRows = (sessionId, { page, pageSize } = {}) => listSessionDecisions(sessionId, { port, page, pageSize });
|
|
265
|
+
const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, bindHost: web.webServer.host }).map(route => web.webServer.register(route));
|
|
265
266
|
return () => { unregister.forEach(dispose => dispose()); const store = logStore; logStore = undefined; void store?.dispose(); };
|
|
266
267
|
});
|
|
267
268
|
}
|
package/service/log-analyze.mjs
CHANGED
|
@@ -230,3 +230,27 @@ export async function reconstructSessionSeed(sessionId, { dir, port, trendConfig
|
|
|
230
230
|
if (decisions.size === 0) return undefined;
|
|
231
231
|
return buildSeed(decisions, { maxLog, trendConfig });
|
|
232
232
|
}
|
|
233
|
+
|
|
234
|
+
// Read one session's persisted decision rows and return a single newest-first
|
|
235
|
+
// page. Backs the analysis tab's full decision log (the dock panel still shows
|
|
236
|
+
// only the in-memory recent rows). Decisions are folded by id (latest wins) so a
|
|
237
|
+
// re-logged id is not double-counted. Rows carry only the panel fields — tool
|
|
238
|
+
// name, outcome, counts — never gate command/path, which is never logged. The
|
|
239
|
+
// internal sessionId tag is stripped before the row leaves the server.
|
|
240
|
+
export async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, readStream = createReadableStream } = {}) {
|
|
241
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('Invalid session id');
|
|
242
|
+
const size = Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize <= 500 ? pageSize : 100;
|
|
243
|
+
const records = [];
|
|
244
|
+
for await (const record of readStream({ dir, port })) {
|
|
245
|
+
if (!isObject(record)) continue;
|
|
246
|
+
if (record.sessionId === sessionId) records.push(record);
|
|
247
|
+
}
|
|
248
|
+
const { decisions } = foldSession(records);
|
|
249
|
+
const rows = [...decisions.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)); // newest first
|
|
250
|
+
const total = rows.length;
|
|
251
|
+
const pages = Math.max(1, Math.ceil(total / size));
|
|
252
|
+
const current = Math.min(Math.max(1, Number.isSafeInteger(page) ? page : 1), pages);
|
|
253
|
+
const start = (current - 1) * size;
|
|
254
|
+
const entries = rows.slice(start, start + size).map(({ sessionId: _sessionId, ...rest }) => rest);
|
|
255
|
+
return { entries, total, page: current, pageSize: size, pages };
|
|
256
|
+
}
|
package/service/manager-api.mjs
CHANGED
|
@@ -37,7 +37,7 @@ async function readBody(req) {
|
|
|
37
37
|
return value;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function createManagerRoutes({ path, sessions, backend, logStore, analyze, env = process.env, bindHost = '127.0.0.1' }) {
|
|
40
|
+
export function createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, env = process.env, bindHost = '127.0.0.1' }) {
|
|
41
41
|
const route = (suffix, methods) => ({ kind: 'exact', path: `${API_PREFIX}/${suffix}`, handler: async (req, res) => {
|
|
42
42
|
if (bindHost !== '127.0.0.1' || !authorizedBrowser(req)) return respond(res, 403, { ok: false, error: 'Local same-origin browser request required' });
|
|
43
43
|
if (!Object.hasOwn(methods, req.method)) return respond(res, 405, { ok: false, error: 'Method not allowed' });
|
|
@@ -90,6 +90,21 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
|
|
|
90
90
|
catch (error) { if (error?.code === 'BAD_REQUEST') throw error; const wrapped = new Error('analysis failed'); wrapped.code = 'INTERNAL'; throw wrapped; }
|
|
91
91
|
},
|
|
92
92
|
}),
|
|
93
|
+
// One newest-first page of this session's full persisted decision rows, for
|
|
94
|
+
// the analysis tab's paginated log. Rows carry only panel fields (tool name,
|
|
95
|
+
// outcome, counts) — never gate command/path, which is never logged — so this
|
|
96
|
+
// exposes nothing the recent-rows /log route does not already.
|
|
97
|
+
route('logrows', {
|
|
98
|
+
GET: async req => {
|
|
99
|
+
if (typeof listRows !== 'function') { const error = new Error('log rows unavailable'); error.code = 'BAD_REQUEST'; throw error; }
|
|
100
|
+
const id = sessionId(req);
|
|
101
|
+
const params = new URL(req.url ?? '/', 'http://localhost').searchParams;
|
|
102
|
+
const page = Number.parseInt(params.get('page') ?? '1', 10);
|
|
103
|
+
const pageSize = Number.parseInt(params.get('pageSize') ?? '100', 10);
|
|
104
|
+
try { return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize }); }
|
|
105
|
+
catch (error) { if (error?.code === 'BAD_REQUEST') throw error; const wrapped = new Error('log rows failed'); wrapped.code = 'INTERNAL'; throw wrapped; }
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
93
108
|
// Append one annotation event for a decision. Explicit user intent: validated
|
|
94
109
|
// and written immediately; a write failure surfaces as an error, not silence.
|
|
95
110
|
route('annotate', {
|