dsh-decision-layer 0.5.6 → 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 +3 -2
- package/README.md +3 -2
- package/lib/client.js +49 -21
- package/package.json +1 -1
- package/service/dsh.mjs +25 -3
- package/service/log-analyze.mjs +101 -0
- package/service/manager-api.mjs +20 -4
- package/service/session-state.mjs +111 -17
- package/service/state-store.mjs +100 -0
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.
|
|
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
|
|
|
@@ -46,7 +46,8 @@ Restart `dsh web` and refresh after installing or updating: the host and client
|
|
|
46
46
|
- **Decision-analysis view** — an in-session "Decision analysis" tab (`conversation.view`, alongside chat and trajectory, naturally scoped per session) that reads the persisted log read-only and shows a process profile (per-point attempts and outcome distribution, mean self-check score), a quality-trend threshold backtest, and a decision-accuracy summary, and lets you rate each decision correct / wrong / unsure. Ratings share one source with the intervention panel: a rating on either surface writes to the same log, keyed by decision id, latest wins. Read-only over logs, append-only for annotations; it changes no decision behavior.
|
|
47
47
|
- **Per-feature switches** — the config page has an independent switch for the danger gate, output self-check, and tool narrowing, all on by default; turning one off drops that intervention. The loop guard is not covered by these switches.
|
|
48
48
|
- **Persisted decision log** — beyond the in-memory panel, decisions are written in full per session to `~/.config/dsh-decision-layer/logs` for research. Files roll by running-instance port and date (`decisions-<port>-YYYY-MM-DD.jsonl`), are batched, and retained for 30 days. Writing is best-effort: a failed flush drops that batch and never breaks the decision path.
|
|
49
|
+
- **Persisted session metrics** — the panel's live metrics (dock count, per-decision-point cards, recent decision log) no longer reset to zero after a restart. Each session's live state is mirrored, keyed by session id (not port), to `~/.config/dsh-decision-layer/state/<sessionId>.json`, written debounced and atomically on every change and retained for 30 days. A resumed session restores from that snapshot; if it has no snapshot yet but has persisted log history (a session predating this mechanism, or whose snapshot was pruned), a seed is reconstructed from the logs across all ports by session id — the one exception is "actual" gate outcomes (observed allow/deny), which are not in the log and resume from zero. Writing is likewise best-effort and never enters the decision path.
|
|
49
50
|
|
|
50
51
|
## Safety boundary and development
|
|
51
52
|
|
|
52
|
-
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.
|
|
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.
|
|
7
|
+
> **0.6.1 是体验版。** 插件功能已可用,但裁决介入能带来多少实际价值仍在探索中——各决策点的阈值都是未经验证的启发式默认值,建议先以观察心态体验,别当成经过验证的效果保证。功能与后续进度以 [ROADMAP.md](<ROADMAP.md>) 为准。
|
|
8
8
|
|
|
9
9
|
## 安装
|
|
10
10
|
|
|
@@ -46,6 +46,7 @@ dsh plugin --profile web add file:/绝对路径/dsh-decision-layer
|
|
|
46
46
|
- **决策分析页**:会话内「决策分析」tab(`conversation.view`,与对话/轨迹平级、天然按会话隔离),基于已落盘日志做只读聚合,展示过程画像(各决策点触发数与结果分布、自检平均分)、质量趋势阈值回测、判断准确度汇总,并可对每条决策标注「判对 / 判错 / 说不准」。标注与介入面板同源:任一处标注都写入同一份日志、按决策 id 关联、取最新。只读日志、只追加标注,不改任何决策行为。
|
|
47
47
|
- **决策点开关**:详情页配置里,危险门控、产出自检、工具收窄各有独立开关,默认全开;关掉某个决策点就不再对应地介入。防循环不在此范围。
|
|
48
48
|
- **决策日志落盘**:决策记录在内存面板之外,还按会话全量落盘到 `~/.config/dsh-decision-layer/logs`,供研究回看。文件按运行实例端口和日期滚动切分(`decisions-<端口>-YYYY-MM-DD.jsonl`),批量写入,保留 30 天。写盘为尽力而为,失败只丢当批、不影响决策路径。
|
|
49
|
+
- **会话指标持久化**:面板的实时指标(dock 计数、各决策点卡片、最近决策日志)在重启后不再归零。每个会话的实时状态按会话 id(非端口)另存一份快照到 `~/.config/dsh-decision-layer/state/<会话id>.json`,每次计数变化防抖写盘、原子落盘,保留 30 天。同一会话恢复时优先读该快照续算;若尚无快照但有历史决策日志(如早于本机制、或快照已被清理的会话),则跨全部端口按会话 id 从日志重建种子续算——唯一例外是「实际结果」(gate 观测到的放行 / 拒绝)无法从日志重建,重启后从零重新累计。写盘同为尽力而为,失败绝不进入决策路径。
|
|
49
50
|
|
|
50
51
|
## 安全边界
|
|
51
52
|
|
|
@@ -53,4 +54,4 @@ dsh plugin --profile web add file:/绝对路径/dsh-decision-layer
|
|
|
53
54
|
|
|
54
55
|
## 开发
|
|
55
56
|
|
|
56
|
-
服务端使用 Node.js `>=20.11`(`.mjs`,无需构建);客户端用 TypeScript + esbuild 构建。`npm test` 跑本地测试,`npm run build:client` 生成 `lib/client.js`。改动客户端后需重建并连同 `lib/client.js` 一起提交,否则 GitHub 安装拿到的是旧 bundle。0.
|
|
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.
|
|
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
|
@@ -10,7 +10,8 @@ import { createNarrowing } from './narrowing.mjs';
|
|
|
10
10
|
import { createTaskCompletion } from './task-completion.mjs';
|
|
11
11
|
import { createLoopGuard } from './loop-guard.mjs';
|
|
12
12
|
import { createLogStore } from './log-store.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { createStateStore } from './state-store.mjs';
|
|
14
|
+
import { analyzeSessionLogs, reconstructSessionSeed, listSessionDecisions } from './log-analyze.mjs';
|
|
14
15
|
|
|
15
16
|
export const name = 'dsh-decision-layer';
|
|
16
17
|
export const inject = ['tools', 'skills'];
|
|
@@ -100,7 +101,23 @@ export async function apply(ctx, options = {}) {
|
|
|
100
101
|
// The research log sink is attached once the web server reveals the instance
|
|
101
102
|
// port (used in the file name); until then decision logs stay in memory only.
|
|
102
103
|
let logStore;
|
|
103
|
-
|
|
104
|
+
// Durable per-session mirror of the live decision counters/log, keyed by
|
|
105
|
+
// session id (not port), so a resumed session's panel survives an instance
|
|
106
|
+
// restart instead of resetting to zero. Independent of the JSONL research log.
|
|
107
|
+
const stateStore = createStateStore();
|
|
108
|
+
const sessions = createSessionState({
|
|
109
|
+
onLog: entry => logStore?.append(entry),
|
|
110
|
+
// Restore a resumed session from its durable snapshot; if it has none yet but
|
|
111
|
+
// has persisted JSONL decision history (e.g. a session that predates this
|
|
112
|
+
// persistence, or whose snapshot was pruned), reconstruct a seed from the
|
|
113
|
+
// logs instead. Logs are read across all ports and filtered by the globally
|
|
114
|
+
// unique session id, so a changed instance port never orphans the history.
|
|
115
|
+
loadState: async id => (await stateStore.load(id)) ?? reconstructSessionSeed(id, {
|
|
116
|
+
trendConfig: (await resolveConfig({ path })).checkSettings,
|
|
117
|
+
}).catch(() => undefined),
|
|
118
|
+
persistState: (id, state) => stateStore.save(id, state),
|
|
119
|
+
});
|
|
120
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => () => { void stateStore.dispose(); });
|
|
104
121
|
const gate = createDangerGate({
|
|
105
122
|
evaluate: (input, request) => backend.evaluate(input, request),
|
|
106
123
|
sessions,
|
|
@@ -185,6 +202,10 @@ export async function apply(ctx, options = {}) {
|
|
|
185
202
|
const agent = payload?.agent;
|
|
186
203
|
const id = agent?.session?.id;
|
|
187
204
|
const turn = payload?.turn;
|
|
205
|
+
// Rehydrate this session's persisted counters/log on its first step this
|
|
206
|
+
// process, before any gate/check/narrow/complete event mutates them, so a
|
|
207
|
+
// resumed session continues from its saved totals instead of from zero.
|
|
208
|
+
if (typeof id === 'string') await sessions.ensureLoaded(id);
|
|
188
209
|
if (typeof id === 'string' && Number.isInteger(turn) && await featureOn('narrow')) {
|
|
189
210
|
const state = narrowedTurns.get(id);
|
|
190
211
|
if (!state || state.turn !== turn) {
|
|
@@ -240,7 +261,8 @@ export async function apply(ctx, options = {}) {
|
|
|
240
261
|
port,
|
|
241
262
|
trendConfig: (await resolveConfig({ path })).checkSettings,
|
|
242
263
|
});
|
|
243
|
-
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));
|
|
244
266
|
return () => { unregister.forEach(dispose => dispose()); const store = logStore; logStore = undefined; void store?.dispose(); };
|
|
245
267
|
});
|
|
246
268
|
}
|
package/service/log-analyze.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createReadableStream } from './log-read.mjs';
|
|
2
|
+
import { advanceRun, severityOf } from './quality-trend.mjs';
|
|
2
3
|
|
|
3
4
|
// Offline analysis over the persisted decision JSONL logs. Pure aggregation:
|
|
4
5
|
// given a stream of records (decision events + annotation events) for one
|
|
@@ -153,3 +154,103 @@ export async function analyzeSessionLogs(sessionId, { dir, port, trendConfig, re
|
|
|
153
154
|
}
|
|
154
155
|
return analyzeSession(records, trendConfig);
|
|
155
156
|
}
|
|
157
|
+
|
|
158
|
+
// Reconstruct a live session-state seed (the counter shape session-state keeps,
|
|
159
|
+
// plus the recent decision log) from this session's folded decisions. Used to
|
|
160
|
+
// bridge a restart for a session that has persisted JSONL history but no state
|
|
161
|
+
// snapshot yet, so its panel resumes from its real totals instead of from zero.
|
|
162
|
+
//
|
|
163
|
+
// Faithful for every counter because each feature logs exactly one record per
|
|
164
|
+
// record*() call with the same outcome — with one documented exception:
|
|
165
|
+
// gate.actual (observed tool outcomes) is never logged, so it starts at zero and
|
|
166
|
+
// resumes as new tool results arrive. The check trend run/severity is replayed
|
|
167
|
+
// with the same advanceRun/severityOf semantics the live path uses.
|
|
168
|
+
export function buildSeed(decisions, { maxLog = 50, trendConfig = {} } = {}) {
|
|
169
|
+
const ordered = [...decisions.values()].filter(record => DECISION_KINDS.has(record.kind)).sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
|
|
170
|
+
const seed = {
|
|
171
|
+
attempts: 0, failures: 0,
|
|
172
|
+
gate: { attempts: 0, failures: 0, ask: 0, deny: 0, allow: 0, actual: { allow: 0, deny: 0, error: 0 } },
|
|
173
|
+
check: { attempts: 0, failures: 0, low: 0, run: 0, severity: 'normal' },
|
|
174
|
+
narrow: { attempts: 0, failures: 0, applied: 0, dropped: 0 },
|
|
175
|
+
complete: { attempts: 0, failures: 0, unsatisfied: 0, satisfied: 0, insufficient: 0, steered: 0 },
|
|
176
|
+
log: [],
|
|
177
|
+
};
|
|
178
|
+
const minConfidence = typeof trendConfig.trendMinConfidence === 'number' ? trendConfig.trendMinConfidence : 0.6;
|
|
179
|
+
const thresholds = { trendRun: trendConfig.trendRun ?? 3, trendSevereRun: trendConfig.trendSevereRun ?? 5 };
|
|
180
|
+
let run = 0;
|
|
181
|
+
for (const record of ordered) {
|
|
182
|
+
const bucket = seed[record.kind];
|
|
183
|
+
seed.attempts++;
|
|
184
|
+
bucket.attempts++;
|
|
185
|
+
const isError = record.outcome === 'error';
|
|
186
|
+
if (isError) { seed.failures++; bucket.failures++; }
|
|
187
|
+
if (record.kind === 'gate') {
|
|
188
|
+
if (['allow', 'ask', 'deny'].includes(record.outcome)) bucket[record.outcome]++;
|
|
189
|
+
} else if (record.kind === 'check') {
|
|
190
|
+
if (!isError) {
|
|
191
|
+
if (record.outcome === 'low') bucket.low++;
|
|
192
|
+
// Replay the trend run exactly as recordCheck does: lowScore keys off the
|
|
193
|
+
// 'low' outcome, confident off the logged confidence vs the threshold.
|
|
194
|
+
const confident = typeof record.confidence === 'number' && Number.isFinite(record.confidence) && record.confidence >= minConfidence;
|
|
195
|
+
run = advanceRun(run, { lowScore: record.outcome === 'low', confident });
|
|
196
|
+
}
|
|
197
|
+
} else if (record.kind === 'narrow') {
|
|
198
|
+
if (record.outcome === 'applied') {
|
|
199
|
+
bucket.applied++;
|
|
200
|
+
if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.dropped += record.dropped;
|
|
201
|
+
}
|
|
202
|
+
} else if (record.kind === 'complete') {
|
|
203
|
+
if (!isError) {
|
|
204
|
+
if (Number.isSafeInteger(record.satisfied)) bucket.satisfied += record.satisfied;
|
|
205
|
+
if (Number.isSafeInteger(record.unsatisfied)) bucket.unsatisfied += record.unsatisfied;
|
|
206
|
+
if (Number.isSafeInteger(record.insufficient)) bucket.insufficient += record.insufficient;
|
|
207
|
+
if (record.steered === true) bucket.steered++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
seed.check.run = run;
|
|
212
|
+
seed.check.severity = severityOf(run, thresholds);
|
|
213
|
+
// The recent-log list: the newest decisions, as plain records (drop sessionId,
|
|
214
|
+
// which the live log does not carry). Already in the panel-record shape.
|
|
215
|
+
seed.log = ordered.slice(-maxLog).map(record => { const { sessionId, ...rest } = record; return rest; });
|
|
216
|
+
return seed;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Read one session's persisted JSONL and reconstruct its live state seed, or
|
|
220
|
+
// undefined when it has no decision history. Streams files like analyzeSessionLogs.
|
|
221
|
+
export async function reconstructSessionSeed(sessionId, { dir, port, trendConfig, maxLog, readStream = createReadableStream } = {}) {
|
|
222
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) return undefined;
|
|
223
|
+
const records = [];
|
|
224
|
+
for await (const record of readStream({ dir, port })) {
|
|
225
|
+
if (!isObject(record)) continue;
|
|
226
|
+
if (record.sessionId === sessionId) records.push(record);
|
|
227
|
+
}
|
|
228
|
+
if (records.length === 0) return undefined;
|
|
229
|
+
const { decisions } = foldSession(records);
|
|
230
|
+
if (decisions.size === 0) return undefined;
|
|
231
|
+
return buildSeed(decisions, { maxLog, trendConfig });
|
|
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' });
|
|
@@ -77,8 +77,8 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
|
|
|
77
77
|
} catch { return { connected: false, reason: 'unavailable', effectiveUrl }; }
|
|
78
78
|
},
|
|
79
79
|
}),
|
|
80
|
-
route('metrics', { GET: req =>
|
|
81
|
-
route('log', { GET: req => ({ entries: sessions.snapshot(
|
|
80
|
+
route('metrics', { GET: async req => { const id = sessionId(req); await sessions.ensureLoaded?.(id); return sessions.snapshot(id); } }),
|
|
81
|
+
route('log', { GET: async req => { const id = sessionId(req); await sessions.ensureLoaded?.(id); return { entries: sessions.snapshot(id).log ?? [] }; } }),
|
|
82
82
|
// Read-only analysis over the persisted decision logs for one session. Returns
|
|
83
83
|
// only aggregates and annotation counts — never raw log rows — so gate command
|
|
84
84
|
// / path fragments are not echoed back to the browser.
|
|
@@ -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', {
|
|
@@ -110,9 +125,10 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
|
|
|
110
125
|
},
|
|
111
126
|
}),
|
|
112
127
|
route('session', {
|
|
113
|
-
GET: req => sessions.snapshot(
|
|
128
|
+
GET: async req => { const id = sessionId(req); await sessions.ensureLoaded?.(id); return sessions.snapshot(id); },
|
|
114
129
|
PUT: async req => {
|
|
115
130
|
const body = await readBody(req);
|
|
131
|
+
await sessions.ensureLoaded?.(body.sessionId);
|
|
116
132
|
sessions.setEnabled(body.sessionId, body.enabled);
|
|
117
133
|
return sessions.snapshot(body.sessionId);
|
|
118
134
|
},
|
|
@@ -18,11 +18,65 @@ function safeTool(value) {
|
|
|
18
18
|
return typeof value === 'string' && value.trim() ? value.trim().slice(0, 128) : undefined;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
const toCount = value => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
|
|
22
|
+
|
|
23
|
+
// Rebuild a trusted in-memory state from a persisted (untrusted) snapshot. Every
|
|
24
|
+
// counter is coerced into the emptyState() shape with a non-negative-integer
|
|
25
|
+
// guard and the trend severity into its enum, so a stale or tampered file can
|
|
26
|
+
// never inject an out-of-shape value into the live counters. The recent log is
|
|
27
|
+
// kept as plain records (newest-last); callers trim it to the log cap.
|
|
28
|
+
function coerceState(raw) {
|
|
29
|
+
const state = emptyState();
|
|
30
|
+
if (!raw || typeof raw !== 'object') return state;
|
|
31
|
+
if (typeof raw.enabled === 'boolean') state.enabled = raw.enabled;
|
|
32
|
+
state.attempts = toCount(raw.attempts);
|
|
33
|
+
state.failures = toCount(raw.failures);
|
|
34
|
+
const g = raw.gate;
|
|
35
|
+
if (g && typeof g === 'object') {
|
|
36
|
+
state.gate.attempts = toCount(g.attempts); state.gate.failures = toCount(g.failures);
|
|
37
|
+
state.gate.ask = toCount(g.ask); state.gate.deny = toCount(g.deny); state.gate.allow = toCount(g.allow);
|
|
38
|
+
const a = g.actual;
|
|
39
|
+
if (a && typeof a === 'object') { state.gate.actual.allow = toCount(a.allow); state.gate.actual.deny = toCount(a.deny); state.gate.actual.error = toCount(a.error); }
|
|
40
|
+
}
|
|
41
|
+
const c = raw.check;
|
|
42
|
+
if (c && typeof c === 'object') {
|
|
43
|
+
state.check.attempts = toCount(c.attempts); state.check.failures = toCount(c.failures); state.check.low = toCount(c.low);
|
|
44
|
+
state.check.run = toCount(c.run);
|
|
45
|
+
state.check.severity = ['normal', 'warn', 'severe'].includes(c.severity) ? c.severity : 'normal';
|
|
46
|
+
}
|
|
47
|
+
const n = raw.narrow;
|
|
48
|
+
if (n && typeof n === 'object') {
|
|
49
|
+
state.narrow.attempts = toCount(n.attempts); state.narrow.failures = toCount(n.failures);
|
|
50
|
+
state.narrow.applied = toCount(n.applied); state.narrow.dropped = toCount(n.dropped);
|
|
51
|
+
}
|
|
52
|
+
const k = raw.complete;
|
|
53
|
+
if (k && typeof k === 'object') {
|
|
54
|
+
state.complete.attempts = toCount(k.attempts); state.complete.failures = toCount(k.failures);
|
|
55
|
+
state.complete.unsatisfied = toCount(k.unsatisfied); state.complete.satisfied = toCount(k.satisfied);
|
|
56
|
+
state.complete.insufficient = toCount(k.insufficient); state.complete.steered = toCount(k.steered);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(raw.log)) {
|
|
59
|
+
state.log = raw.log
|
|
60
|
+
.filter(entry => entry && typeof entry === 'object' && !Array.isArray(entry) && typeof entry.kind === 'string')
|
|
61
|
+
.map(entry => ({ ...entry }));
|
|
62
|
+
}
|
|
63
|
+
return state;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onLog, loadState, persistState } = {}) {
|
|
22
67
|
if (!Number.isSafeInteger(maxSessions) || maxSessions < 1) throw new Error('Invalid session limit');
|
|
23
68
|
if (!Number.isSafeInteger(maxLogEntries) || maxLogEntries < 1) throw new Error('Invalid log limit');
|
|
24
69
|
const sessions = new Map();
|
|
25
70
|
const sink = typeof onLog === 'function' ? onLog : undefined;
|
|
71
|
+
const load = typeof loadState === 'function' ? loadState : undefined;
|
|
72
|
+
const persist = typeof persistState === 'function' ? persistState : undefined;
|
|
73
|
+
// Memoize rehydration per id so a session is seeded from disk at most once per
|
|
74
|
+
// process. Bounded like `sessions`; dropping a memo entry only risks a
|
|
75
|
+
// redundant disk read that no-ops (ensureLoaded never clobbers live state).
|
|
76
|
+
const loaded = new Map();
|
|
77
|
+
// Mirror the latest live state to the durable store after every mutation.
|
|
78
|
+
// Best-effort: a persistence fault must never surface in the decision path.
|
|
79
|
+
const bump = (key, state) => { if (persist) { try { persist(key, state); } catch { /* best effort */ } } };
|
|
26
80
|
// FIFO eviction: when the map is full, drop the oldest-inserted session before
|
|
27
81
|
// admitting a new one (Map preserves insertion order, so the first key is the
|
|
28
82
|
// oldest). This mirrors loop-guard / narrowing and means the store never
|
|
@@ -40,9 +94,38 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
40
94
|
return state;
|
|
41
95
|
};
|
|
42
96
|
return {
|
|
97
|
+
// Rehydrate a session's live counters/log from the durable store the first
|
|
98
|
+
// time it is touched this process, before any new event mutates it — so disk
|
|
99
|
+
// history and in-process events never double-count. Memoized per id; a no-op
|
|
100
|
+
// when no loadState was given or the session already has live state. Callers
|
|
101
|
+
// should await this before the first snapshot/record of a resumed session
|
|
102
|
+
// (the plugin does so at the turn's first step and in the panel routes).
|
|
103
|
+
async ensureLoaded(id) {
|
|
104
|
+
if (!load) return;
|
|
105
|
+
let key;
|
|
106
|
+
try { key = sessionKey(id); } catch { return; }
|
|
107
|
+
const existing = loaded.get(key);
|
|
108
|
+
if (existing) return existing;
|
|
109
|
+
const promise = (async () => {
|
|
110
|
+
if (sessions.has(key)) return; // live events already created state; keep it
|
|
111
|
+
let raw;
|
|
112
|
+
try { raw = await load(key); } catch { raw = undefined; }
|
|
113
|
+
if (!raw) return;
|
|
114
|
+
if (sessions.has(key)) return; // a concurrent mutation won the race; do not clobber
|
|
115
|
+
const state = coerceState(raw);
|
|
116
|
+
if (state.log.length > maxLogEntries) state.log.splice(0, state.log.length - maxLogEntries);
|
|
117
|
+
if (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
|
|
118
|
+
sessions.set(key, state);
|
|
119
|
+
})();
|
|
120
|
+
if (loaded.size >= maxSessions) loaded.delete(loaded.keys().next().value);
|
|
121
|
+
loaded.set(key, promise);
|
|
122
|
+
return promise;
|
|
123
|
+
},
|
|
43
124
|
setEnabled(id, enabled) {
|
|
44
125
|
if (typeof enabled !== 'boolean') throw new Error('Invalid enabled value');
|
|
45
|
-
writable(id)
|
|
126
|
+
const state = writable(id);
|
|
127
|
+
state.enabled = enabled;
|
|
128
|
+
bump(sessionKey(id), state);
|
|
46
129
|
},
|
|
47
130
|
snapshot(id) {
|
|
48
131
|
const stored = sessions.get(sessionKey(id));
|
|
@@ -62,26 +145,32 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
62
145
|
state.gate.attempts++;
|
|
63
146
|
if (outcome === 'error') { state.failures++; state.gate.failures++; }
|
|
64
147
|
else state.gate[outcome]++;
|
|
148
|
+
bump(sessionKey(id), state);
|
|
65
149
|
},
|
|
66
150
|
recordActual(id, outcome) {
|
|
67
151
|
if (!['allow', 'deny', 'error'].includes(outcome)) throw new Error('Invalid actual decision');
|
|
68
|
-
writable(id)
|
|
152
|
+
const state = writable(id);
|
|
153
|
+
state.gate.actual[outcome]++;
|
|
154
|
+
bump(sessionKey(id), state);
|
|
69
155
|
},
|
|
70
156
|
recordCheck(id, outcome, trend) {
|
|
71
157
|
if (!['ok', 'low', 'error'].includes(outcome)) throw new Error('Invalid self-check outcome');
|
|
72
158
|
const state = writable(id);
|
|
73
159
|
state.attempts++;
|
|
74
160
|
state.check.attempts++;
|
|
75
|
-
if (outcome === 'error') { state.failures++; state.check.failures++;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
161
|
+
if (outcome === 'error') { state.failures++; state.check.failures++; }
|
|
162
|
+
else {
|
|
163
|
+
if (outcome === 'low') state.check.low++;
|
|
164
|
+
// Advance the quality-trend run over this evaluated score. A good score
|
|
165
|
+
// resets the run; a high-confidence low score grows it; a low-confidence
|
|
166
|
+
// low score neither resets nor grows. `trend` carries the per-sample flags
|
|
167
|
+
// and the configured thresholds; absent trend leaves the run unchanged.
|
|
168
|
+
if (trend && typeof trend === 'object') {
|
|
169
|
+
state.check.run = advanceRun(state.check.run, { lowScore: outcome === 'low', confident: trend.confident === true });
|
|
170
|
+
state.check.severity = severityOf(state.check.run, { trendRun: trend.trendRun, trendSevereRun: trend.trendSevereRun });
|
|
171
|
+
}
|
|
84
172
|
}
|
|
173
|
+
bump(sessionKey(id), state);
|
|
85
174
|
},
|
|
86
175
|
recordNarrow(id, outcome, dropped = 0) {
|
|
87
176
|
if (!['ok', 'applied', 'error'].includes(outcome)) throw new Error('Invalid narrowing outcome');
|
|
@@ -93,6 +182,7 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
93
182
|
state.narrow.applied++;
|
|
94
183
|
if (Number.isSafeInteger(dropped) && dropped > 0) state.narrow.dropped += dropped;
|
|
95
184
|
}
|
|
185
|
+
bump(sessionKey(id), state);
|
|
96
186
|
},
|
|
97
187
|
// Task-completion checking. `ok`/`unsatisfied` are real evaluations; `error`
|
|
98
188
|
// is an unevaluated failure counted as a failure, never as "unsatisfied".
|
|
@@ -102,11 +192,14 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
102
192
|
const state = writable(id);
|
|
103
193
|
state.attempts++;
|
|
104
194
|
state.complete.attempts++;
|
|
105
|
-
if (outcome === 'error') { state.failures++; state.complete.failures++;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
195
|
+
if (outcome === 'error') { state.failures++; state.complete.failures++; }
|
|
196
|
+
else {
|
|
197
|
+
if (Number.isSafeInteger(detail.satisfied) && detail.satisfied > 0) state.complete.satisfied += detail.satisfied;
|
|
198
|
+
if (Number.isSafeInteger(detail.unsatisfied) && detail.unsatisfied > 0) state.complete.unsatisfied += detail.unsatisfied;
|
|
199
|
+
if (Number.isSafeInteger(detail.insufficient) && detail.insufficient > 0) state.complete.insufficient += detail.insufficient;
|
|
200
|
+
if (detail.steered === true) state.complete.steered++;
|
|
201
|
+
}
|
|
202
|
+
bump(sessionKey(id), state);
|
|
110
203
|
},
|
|
111
204
|
log(id, entry) {
|
|
112
205
|
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('Invalid log entry');
|
|
@@ -149,6 +242,7 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
|
|
|
149
242
|
}
|
|
150
243
|
try { sink(full); } catch { /* persistence is best effort */ }
|
|
151
244
|
}
|
|
245
|
+
bump(sessionKey(id), state);
|
|
152
246
|
},
|
|
153
247
|
};
|
|
154
248
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export function stateDir() {
|
|
7
|
+
return join(homedir(), '.config', 'dsh-decision-layer', 'state');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A session id is persisted only when it is safe as a single path segment, so a
|
|
11
|
+
// crafted id can never escape the state directory. dsh session ids are
|
|
12
|
+
// `session-<uuid>`, which pass; anything with separators, leading dot, `..`, or
|
|
13
|
+
// an unreasonable length is skipped (that session simply stays in-memory only).
|
|
14
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
15
|
+
export function safeStateId(id) {
|
|
16
|
+
return typeof id === 'string' && SAFE_ID.test(id) && !id.includes('..') ? id : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Durable per-session mirror of the live decision counters + recent log. One
|
|
20
|
+
// small JSON file per session id (`<id>.json`), written debounced and atomically
|
|
21
|
+
// (unique tmp + rename), pruned by age on an occasional flush. This is separate
|
|
22
|
+
// from the JSONL research log: it exists only so a resumed session's live panel
|
|
23
|
+
// (dock count, metric cards, recent log) survives an instance restart instead of
|
|
24
|
+
// resetting to zero. All IO is best-effort — a failure never throws into the
|
|
25
|
+
// decision path; at worst the panel loses the last few seconds of counters.
|
|
26
|
+
export function createStateStore({ dir = stateDir(), flushIntervalMs = 2000,
|
|
27
|
+
retentionDays = 30, now = () => Date.now() } = {}) {
|
|
28
|
+
const pending = new Map(); // id -> latest live state reference awaiting a flush
|
|
29
|
+
let timer;
|
|
30
|
+
let flushing = false;
|
|
31
|
+
let lastPrune = 0;
|
|
32
|
+
|
|
33
|
+
const fileFor = id => join(dir, `${id}.json`);
|
|
34
|
+
|
|
35
|
+
const writeOne = async (id, state) => {
|
|
36
|
+
const temporary = join(dir, `${id}.${randomUUID()}.tmp`);
|
|
37
|
+
try {
|
|
38
|
+
await writeFile(temporary, `${JSON.stringify({ v: 1, at: now(), state })}\n`, { mode: 0o600, flag: 'wx' });
|
|
39
|
+
await rename(temporary, fileFor(id));
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const prune = async () => {
|
|
46
|
+
let names;
|
|
47
|
+
try { names = await readdir(dir); } catch { return; }
|
|
48
|
+
const cutoff = now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
49
|
+
await Promise.all(names.map(async name => {
|
|
50
|
+
if (!name.endsWith('.json')) return;
|
|
51
|
+
try {
|
|
52
|
+
const info = await stat(join(dir, name));
|
|
53
|
+
if (info.mtimeMs < cutoff) await rm(join(dir, name), { force: true }).catch(() => {});
|
|
54
|
+
} catch { /* a file vanishing under us is fine */ }
|
|
55
|
+
}));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const flush = async () => {
|
|
59
|
+
if (flushing || pending.size === 0) return;
|
|
60
|
+
flushing = true;
|
|
61
|
+
// Snapshot ids now; each state is JSON.stringify'd at write time so the file
|
|
62
|
+
// reflects the latest counters, which is exactly what we want.
|
|
63
|
+
const batch = [...pending.entries()];
|
|
64
|
+
pending.clear();
|
|
65
|
+
try {
|
|
66
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
67
|
+
for (const [id, state] of batch) await writeOne(id, state).catch(() => {});
|
|
68
|
+
// Prune at most about once per hour of activity, not on every flush.
|
|
69
|
+
if (now() - lastPrune > 60 * 60 * 1000) { lastPrune = now(); await prune(); }
|
|
70
|
+
} catch { /* best effort; a dropped flush must never break the decision path */ }
|
|
71
|
+
finally { flushing = false; }
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
// Read a session's persisted state, or undefined when there is no usable file.
|
|
76
|
+
async load(id) {
|
|
77
|
+
const safe = safeStateId(id);
|
|
78
|
+
if (!safe) return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(await readFile(fileFor(safe), 'utf8'));
|
|
81
|
+
if (parsed && typeof parsed === 'object' && parsed.state && typeof parsed.state === 'object' && !Array.isArray(parsed.state)) {
|
|
82
|
+
return parsed.state;
|
|
83
|
+
}
|
|
84
|
+
} catch { /* missing or malformed: no seed, start empty */ }
|
|
85
|
+
return undefined;
|
|
86
|
+
},
|
|
87
|
+
// Register the latest live state for a session and schedule a debounced flush.
|
|
88
|
+
save(id, state) {
|
|
89
|
+
const safe = safeStateId(id);
|
|
90
|
+
if (!safe || state === null || typeof state !== 'object') return;
|
|
91
|
+
pending.set(safe, state);
|
|
92
|
+
if (!timer) timer = setTimeout(() => { timer = undefined; void flush(); }, flushIntervalMs);
|
|
93
|
+
},
|
|
94
|
+
flush,
|
|
95
|
+
dispose() {
|
|
96
|
+
if (timer) { clearTimeout(timer); timer = undefined; }
|
|
97
|
+
return flush();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|