@lmzhen/dsh-evolution-approval 0.15.2 → 0.16.0

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.md CHANGED
@@ -5,7 +5,9 @@ one-shot; this service adds the Hermes staged queue: `request()` stores backgrou
5
5
  `approve()` replays them through a runner, `reject()` discards them. The model-facing write tools
6
6
  register their own runners (`tool-memory` via `registerRunner('memory', …)`, `tool-skill-manage`
7
7
  via `registerRunner('skill', …)`), so staged writes are replayable only when the corresponding
8
- tool package is composed.
8
+ tool package is composed. The pending window is also reachable from the browser half over four
9
+ loopback routes (§HTTP surface), so the settings panel can list, decide and PREVIEW staged writes
10
+ without the console command.
9
11
 
10
12
  Tests:
11
13
 
@@ -24,15 +26,46 @@ node node_modules/vitest/vitest.mjs run packages/evolution-approval/tests # ov
24
26
  - `enabled` (default `false`): master switch (off = ungated).
25
27
  - `stageForeground` (default `true`): stage foreground writes too.
26
28
 
29
+ ## HTTP surface
30
+
31
+ Four loopback routes over the same service methods the console command uses, so the two faces cannot drift:
32
+
33
+ | route | what it does |
34
+ |---|---|
35
+ | `GET /api/dsh-evolution/approval/pending?status=` | the staged rows the settings card renders (id, kind, summary, staged-at, age) — never the staged `args` |
36
+ | `POST …/approve`, `POST …/reject` | forward to `approve(id)` / `reject(id)`; a refusal is HTTP 200 carrying the service's own sentence |
37
+ | `GET …/preview?id=` | what that write WOULD store, resolved read-only |
38
+
39
+ The fence is the family's shared loopback check (`evolution-core`'s `http-routes.ts`, one home for every
40
+ route surface), and every "cannot answer" path is a JSON body with a `code` and a `message` rather than an
41
+ empty response.
42
+
43
+ **Previews** come from the package that OWNS the replay: `registerRunner(kind, runner, preview?)` takes an
44
+ optional read-only companion that resolves the same `args` into `{path, before, after}`, and the route turns
45
+ that into the same diff facts the history panel's diff route uses (`textDiffFacts`). A kind without a
46
+ preview, or a resolver that cannot answer (a stale anchor, an operation with no byte-level change), answers
47
+ `{available: false, reason}` — the card prints that sentence instead of an empty diff.
48
+
27
49
  ## Known limitations
28
50
 
51
+ - **Freshness re-validation is per KIND:** a skill write carries `staged_from_sha256`, so its replay (and its
52
+ preview) refuses a target that changed after staging; kinds that carry no anchor replay the `args` snapshot
53
+ as-is. `/evolution pending --detail` shows what will be replayed.
54
+ - **A preview is a read, not a reservation:** it resolves the bytes on disk right now, so a target that
55
+ changes between the preview and the approve is refused by the replay's own anchor check rather than by the
56
+ preview that was shown.
57
+ - **Previews exist only where a resolver is registered** (`skill` today; `tool-skill-manage` supplies it). Every
58
+ other kind answers `{available: false, reason}`.
29
59
  - `approve()` dedupes inside one process and providers resolve the record atomically, but the replay runner executes BEFORE that resolution: two OS processes approving one id can both write while one wins the audit transition. Run approvals from one writer process, or make runners idempotent.
30
60
  - **Approve + reject on one id** are not serialized inside a process: a reject can resolve a still-executing record while the runner completes and the write still lands: verify the write state, or reject only when no approve is in flight.
31
- - **No freshness re-validation:** the staged `args` snapshot is replayed as-is (no content hash), so `approve()` never checks whether the target changed; `/evolution pending --detail` shows what will be replayed.
32
61
 
33
62
  **Runtime invariant:** No companion is published. The platform auto-assembles nothing and the family mounts no `<pkg>/invariant` cordis row, so a companion here would never execute (v37 S2.1 / I-3).
34
63
 
35
64
  ## Notes and history
36
65
 
66
+ - **The HTTP face + preview seam (G3, 2026-09-30).** The routes exist so a reader can act on the window
67
+ without the console; the preview is registered BESIDE the runner because a preview derived anywhere else
68
+ would drift from what the replay writes (the write path owns its own normalization).
69
+
37
70
  - **Concurrent approve + reject on the same id (F-204).** Inside one process the dedupe keys are `approve:<id>` / `reject:<id>`, so the two paths are not serialized against each other. When an approve runner is slow, a reject resolves the still-executing record to `rejected` **without holding a claim**; the runner may then complete and the write can still land while the audit history reads `rejected`: the write effect, not the audit verdict, is what actually persists (`写效果以实际为准`). `reject` on an executing record reports this and asks you to verify the write state manually. Only reject a write after confirming no approve is in flight, or verify the write effect manually afterwards.
38
71
  - **No staged-content freshness re-validation (F-328).** The staged record stores the `args` snapshot captured at request time and replays exactly those args, but does not record a content hash, so `approve()` does **not** re-check whether the on-disk skill/memory the write targets changed since staging. The write is applied as staged regardless. The pending surface (`/evolution pending --detail`) exposes the staged `args` so you can review what will actually be replayed before approving; there is no automatic drift warning if the target changed in the meantime.
package/lib/index.js CHANGED
@@ -1,7 +1,223 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
2
  import { randomUUID } from "node:crypto";
3
- import { isProcessAlive, parseLockBody } from "@lmzhen/dsh-evolution-core";
3
+ import { elapsedSince, isLoopbackRequest, isProcessAlive, parseLockBody, readJsonObject, textDiffFacts, writeJson } from "@lmzhen/dsh-evolution-core";
4
4
  import z from "@deepseek-ai/schemastery";
5
+ //#region lib/types/routes.js
6
+ /**
7
+ * The approval HTTP surface: three loopback routes over the write gate's pending window.
8
+ *
9
+ * Layering: this module maps a route to a USE CASE and does nothing else. The pending window itself
10
+ * (ids, statuses, claim/resolve rules, the replay runners) lives in this package's service and in
11
+ * `evolution-state-storage`; the routes only read the window and forward the two decisions, so the
12
+ * console command face (`/evolution pending`, `approve <id>`) and this one cannot drift — both call
13
+ * the same methods.
14
+ *
15
+ * The browser half never touches the state medium: it lists, it approves, it rejects.
16
+ * @module @lmzhen/dsh-evolution-approval/routes
17
+ */
18
+ /** Route paths. The browser half mirrors these literals; a spec asserts the two sides agree. */
19
+ const APPROVAL_ROUTES = {
20
+ pending: "/api/dsh-evolution/approval/pending",
21
+ approve: "/api/dsh-evolution/approval/approve",
22
+ reject: "/api/dsh-evolution/approval/reject",
23
+ preview: "/api/dsh-evolution/approval/preview"
24
+ };
25
+ /**
26
+ * The statuses a caller may ask for, in the order the window moves through them. Annotated with the
27
+ * storage type so a change to that union fails to compile here instead of drifting silently.
28
+ */
29
+ const PENDING_STATUSES = [
30
+ "pending",
31
+ "executing",
32
+ "approved",
33
+ "rejected"
34
+ ];
35
+ /**
36
+ * Build the three routes over the approval service.
37
+ * @param approval - the host's approval face (the service instance, or a spec fixture).
38
+ * @returns the route list for `ctx.webServer.register`.
39
+ */
40
+ function makeApprovalRoutes(approval) {
41
+ /** Guard: trust fence, then method. Every refusal is a JSON body, never an empty response. */
42
+ const guard = (req, res, method) => {
43
+ if (!isLoopbackRequest(req)) {
44
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
45
+ return false;
46
+ }
47
+ if (req.method !== method) {
48
+ writeJson(res, 405, { error: "method not allowed: " + method });
49
+ return false;
50
+ }
51
+ return true;
52
+ };
53
+ /**
54
+ * Run one decision against the service.
55
+ *
56
+ * A refusal is a BUSINESS answer, not a transport failure: the body carries the service's own
57
+ * sentence — the same one the console command prints — so the two faces cannot drift. A throw is
58
+ * reported the same way rather than escaping as an empty 500, because the operator's next step
59
+ * ("reject it", "retry") is only actionable if the reason is visible.
60
+ */
61
+ const decide = async (res, run, id) => {
62
+ try {
63
+ const result = await run(id);
64
+ writeJson(res, 200, result.ok ? {
65
+ ok: true,
66
+ data: result
67
+ } : {
68
+ ok: false,
69
+ code: "approval-refused",
70
+ message: result.message
71
+ });
72
+ } catch (error) {
73
+ writeJson(res, 200, {
74
+ ok: false,
75
+ code: "approval-failed",
76
+ message: error instanceof Error ? error.message : String(error)
77
+ });
78
+ }
79
+ };
80
+ /** The one shape a decision route accepts: a non-empty string id. */
81
+ const idOf = (payload) => typeof payload?.id === "string" ? payload.id.trim() : "";
82
+ return [
83
+ {
84
+ kind: "exact",
85
+ path: APPROVAL_ROUTES.pending,
86
+ handler: async (req, res) => {
87
+ if (!guard(req, res, "GET")) return;
88
+ const raw = new URL(req.url ?? "/", "http://loopback").searchParams.get("status")?.trim() ?? "";
89
+ const status = raw === "" ? "pending" : raw;
90
+ if (!PENDING_STATUSES.includes(status)) {
91
+ writeJson(res, 400, {
92
+ ok: false,
93
+ code: "bad-request",
94
+ message: "status must be one of " + PENDING_STATUSES.join(", ")
95
+ });
96
+ return;
97
+ }
98
+ try {
99
+ writeJson(res, 200, {
100
+ ok: true,
101
+ data: (await approval.list(status)).map((record) => ({
102
+ id: record.id,
103
+ kind: record.kind,
104
+ summary: record.summary,
105
+ createdAt: record.createdAt,
106
+ age: elapsedSince(record.createdAt, Date.now()),
107
+ status: record.status,
108
+ ...record.claimedBy === void 0 ? {} : { claimedBy: record.claimedBy }
109
+ }))
110
+ });
111
+ } catch (error) {
112
+ writeJson(res, 200, {
113
+ ok: false,
114
+ code: "approval-failed",
115
+ message: error instanceof Error ? error.message : String(error)
116
+ });
117
+ }
118
+ }
119
+ },
120
+ {
121
+ kind: "exact",
122
+ path: APPROVAL_ROUTES.preview,
123
+ handler: async (req, res) => {
124
+ if (!guard(req, res, "GET")) return;
125
+ const id = new URL(req.url ?? "/", "http://loopback").searchParams.get("id")?.trim() ?? "";
126
+ if (id === "") {
127
+ writeJson(res, 400, {
128
+ ok: false,
129
+ code: "bad-request",
130
+ message: "id is required"
131
+ });
132
+ return;
133
+ }
134
+ try {
135
+ const record = [...await approval.list("pending"), ...await approval.list("executing")].find((candidate) => candidate.id === id);
136
+ if (record === void 0) {
137
+ writeJson(res, 200, {
138
+ ok: false,
139
+ code: "not-found",
140
+ message: "no staged write with id \"" + id + "\""
141
+ });
142
+ return;
143
+ }
144
+ const preview = approval.previewOf(record.kind);
145
+ if (preview === void 0) {
146
+ writeJson(res, 200, {
147
+ ok: true,
148
+ data: {
149
+ available: false,
150
+ reason: "a \"" + record.kind + "\" write has no preview"
151
+ }
152
+ });
153
+ return;
154
+ }
155
+ const answer = await preview(record.args);
156
+ if (!answer.available) {
157
+ writeJson(res, 200, {
158
+ ok: true,
159
+ data: {
160
+ available: false,
161
+ reason: answer.reason
162
+ }
163
+ });
164
+ return;
165
+ }
166
+ const facts = textDiffFacts(answer.before ?? "", answer.after ?? "", answer.path);
167
+ writeJson(res, 200, {
168
+ ok: true,
169
+ data: {
170
+ available: true,
171
+ path: answer.path,
172
+ ...facts
173
+ }
174
+ });
175
+ } catch (error) {
176
+ writeJson(res, 200, {
177
+ ok: false,
178
+ code: "approval-failed",
179
+ message: error instanceof Error ? error.message : String(error)
180
+ });
181
+ }
182
+ }
183
+ },
184
+ {
185
+ kind: "exact",
186
+ path: APPROVAL_ROUTES.approve,
187
+ handler: async (req, res) => {
188
+ if (!guard(req, res, "POST")) return;
189
+ const id = idOf(await readJsonObject(req));
190
+ if (id === "") {
191
+ writeJson(res, 400, {
192
+ ok: false,
193
+ code: "bad-request",
194
+ message: "id is required"
195
+ });
196
+ return;
197
+ }
198
+ await decide(res, (current) => approval.approve(current), id);
199
+ }
200
+ },
201
+ {
202
+ kind: "exact",
203
+ path: APPROVAL_ROUTES.reject,
204
+ handler: async (req, res) => {
205
+ if (!guard(req, res, "POST")) return;
206
+ const id = idOf(await readJsonObject(req));
207
+ if (id === "") {
208
+ writeJson(res, 400, {
209
+ ok: false,
210
+ code: "bad-request",
211
+ message: "id is required"
212
+ });
213
+ return;
214
+ }
215
+ await decide(res, (current) => approval.reject(current), id);
216
+ }
217
+ }
218
+ ];
219
+ }
220
+ //#endregion
5
221
  //#region lib/types/index.js
6
222
  /**
7
223
  * Stage/pending write approval for self-evolution mutations.
@@ -45,11 +261,20 @@ var EvolutionApproval = class extends Service {
45
261
  return this.stageForegroundConfig;
46
262
  }
47
263
  runners = /* @__PURE__ */ new Map();
264
+ previews = /* @__PURE__ */ new Map();
48
265
  inFlight = /* @__PURE__ */ new Map();
49
266
  constructor(ctx, config = {}) {
50
267
  super(ctx, "evolutionApproval");
51
268
  this.enabled = config.enabled ?? false;
52
269
  this.stageForegroundConfig = config.stageForeground ?? true;
270
+ this.ctx.inject(["webServer"], (scope) => {
271
+ scope.effect(() => {
272
+ const disposers = makeApprovalRoutes(this).map((route) => scope.webServer.register(route));
273
+ return () => {
274
+ for (const dispose of disposers) dispose();
275
+ };
276
+ }, "evolution-approval: routes");
277
+ });
53
278
  }
54
279
  state() {
55
280
  return this.ctx.evolutionState;
@@ -58,13 +283,19 @@ var EvolutionApproval = class extends Service {
58
283
  get isEnabled() {
59
284
  return this.enabled;
60
285
  }
61
- registerRunner(kind, runner) {
286
+ registerRunner(kind, runner, preview) {
62
287
  if (this.runners.has(kind)) throw new Error(`approval runner for "${kind}" is already registered`);
63
288
  this.runners.set(kind, runner);
289
+ if (preview !== void 0) this.previews.set(kind, preview);
64
290
  return () => {
65
291
  if (this.runners.get(kind) === runner) this.runners.delete(kind);
292
+ if (preview !== void 0 && this.previews.get(kind) === preview) this.previews.delete(kind);
66
293
  };
67
294
  }
295
+ /** The preview registered for `kind`, if that kind can show what its replay would write. */
296
+ previewOf(kind) {
297
+ return this.previews.get(kind);
298
+ }
68
299
  /**
69
300
  * Whether a replay runner is registered for `kind` (rc.42 audit P1-9
70
301
  * pre-check surface): callers that can execute a write directly (the review
@@ -24,6 +24,33 @@ export type WriteRunner = (args: unknown) => Promise<{
24
24
  ok: boolean;
25
25
  message: string;
26
26
  }>;
27
+ /**
28
+ * What one staged write WOULD store, resolved by the package that owns its replay.
29
+ *
30
+ * `available: false` is a business answer, not an error: plenty of staged writes have no bytes worth
31
+ * showing (a request that carries no content, a target whose replay the host cannot resolve yet), and
32
+ * the reader is owed the reason rather than an empty diff.
33
+ */
34
+ export type PreviewAnswer = {
35
+ readonly available: true;
36
+ /** The artifact the bytes belong to, for the reader's label (a file name, or the skill name). */
37
+ readonly path: string;
38
+ /** The bytes the replay would replace; null when the target does not exist yet. */
39
+ readonly before: string | null;
40
+ /** The bytes it would write; null when the operation removes the target. */
41
+ readonly after: string | null;
42
+ } | {
43
+ readonly available: false;
44
+ readonly reason: string;
45
+ };
46
+ /**
47
+ * Resolve one staged write's before/after WITHOUT running it.
48
+ *
49
+ * Registered beside the runner by the same package, and deliberately not derived from it: a preview
50
+ * that re-derived the bytes would drift from what the replay writes, and the family's rule is that the
51
+ * write path owns its own normalization (anchors, frontmatter quoting) — one home, two readers.
52
+ */
53
+ export type WritePreview = (args: unknown) => Promise<PreviewAnswer>;
27
54
  export interface ApprovalRequest {
28
55
  kind: PendingKind;
29
56
  summary: string;
@@ -85,7 +112,7 @@ export type ApprovalLike = {
85
112
  * actually be staged). Optional: absent means "unknown" and callers must
86
113
  * not pre-refuse on it. */
87
114
  stageForeground?: boolean;
88
- registerRunner(kind: PendingKind, runner: WriteRunner): () => void;
115
+ registerRunner(kind: PendingKind, runner: WriteRunner, preview?: WritePreview): () => void;
89
116
  list(status?: PendingStatus): Promise<PendingRecord[]>;
90
117
  approve(id: string): Promise<{
91
118
  ok: boolean;
@@ -133,12 +160,15 @@ export declare class EvolutionApproval extends Service {
133
160
  * all). Mirrors `this.stageForeground`. */
134
161
  get stageForeground(): boolean;
135
162
  private readonly runners;
163
+ private readonly previews;
136
164
  private readonly inFlight;
137
165
  constructor(ctx: Context, config?: Config);
138
166
  private state;
139
167
  /** Fail-closed capability adapters need to distinguish "allowed" from "enabled". */
140
168
  get isEnabled(): boolean;
141
- registerRunner(kind: PendingKind, runner: WriteRunner): () => void;
169
+ registerRunner(kind: PendingKind, runner: WriteRunner, preview?: WritePreview): () => void;
170
+ /** The preview registered for `kind`, if that kind can show what its replay would write. */
171
+ previewOf(kind: PendingKind): WritePreview | undefined;
142
172
  /**
143
173
  * Whether a replay runner is registered for `kind` (rc.42 audit P1-9
144
174
  * pre-check surface): callers that can execute a write directly (the review
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The approval HTTP surface: three loopback routes over the write gate's pending window.
3
+ *
4
+ * Layering: this module maps a route to a USE CASE and does nothing else. The pending window itself
5
+ * (ids, statuses, claim/resolve rules, the replay runners) lives in this package's service and in
6
+ * `evolution-state-storage`; the routes only read the window and forward the two decisions, so the
7
+ * console command face (`/evolution pending`, `approve <id>`) and this one cannot drift — both call
8
+ * the same methods.
9
+ *
10
+ * The browser half never touches the state medium: it lists, it approves, it rejects.
11
+ * @module @lmzhen/dsh-evolution-approval/routes
12
+ */
13
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
14
+ import type { PendingKind, PendingRecord, PendingStatus } from '@lmzhen/dsh-evolution-state-storage';
15
+ import { elapsedSince } from '@lmzhen/dsh-evolution-core';
16
+ import type { WritePreview } from './index.ts';
17
+ /** Route paths. The browser half mirrors these literals; a spec asserts the two sides agree. */
18
+ export declare const APPROVAL_ROUTES: {
19
+ readonly pending: "/api/dsh-evolution/approval/pending";
20
+ readonly approve: "/api/dsh-evolution/approval/approve";
21
+ readonly reject: "/api/dsh-evolution/approval/reject";
22
+ readonly preview: "/api/dsh-evolution/approval/preview";
23
+ };
24
+ /** What one row of the pending list carries to the browser half. */
25
+ export interface PendingRow {
26
+ readonly id: string;
27
+ readonly kind: PendingKind;
28
+ readonly summary: string;
29
+ readonly createdAt: string;
30
+ /** How long ago it was staged, in the family's `{unit, n}` closed vocabulary (the host owns the clock). */
31
+ readonly age: ReturnType<typeof elapsedSince>;
32
+ readonly status: PendingStatus;
33
+ /** Present on a claimed-but-unfinished approve: the operator clears it by rejecting (doctor says so too). */
34
+ readonly claimedBy?: string | undefined;
35
+ }
36
+ /**
37
+ * The slice of the approval service these routes read.
38
+ *
39
+ * Structural on purpose: a spec fixture implements it without constructing the service, and the
40
+ * routes stay usable from any host that exposes the same three methods.
41
+ */
42
+ export interface ApprovalFace {
43
+ list(status?: PendingStatus): Promise<PendingRecord[]>;
44
+ approve(id: string): Promise<{
45
+ ok: boolean;
46
+ message: string;
47
+ }>;
48
+ reject(id: string): Promise<{
49
+ ok: boolean;
50
+ message: string;
51
+ }>;
52
+ /** The preview the replayer of that kind registered, when it registered one. */
53
+ previewOf(kind: PendingKind): WritePreview | undefined;
54
+ }
55
+ /**
56
+ * Build the three routes over the approval service.
57
+ * @param approval - the host's approval face (the service instance, or a spec fixture).
58
+ * @returns the route list for `ctx.webServer.register`.
59
+ */
60
+ export declare function makeApprovalRoutes(approval: ApprovalFace): WebRoute[];
61
+ //# sourceMappingURL=routes.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-approval",
3
3
  "description": "Stage/pending approval service for Hermes-style self-evolution writes (community build)",
4
- "version": "0.15.2",
4
+ "version": "0.16.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,16 +27,18 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.1",
30
- "@lmzhen/dsh-evolution-core": "^0.15.2"
30
+ "@lmzhen/dsh-evolution-core": "^0.16.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@deepseek-ai/cordis": "^4.0.1",
34
- "@lmzhen/dsh-evolution-state-storage": "^0.15.2",
35
- "@lmzhen/dsh-evolution-state": "^0.15.2"
34
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
35
+ "@lmzhen/dsh-evolution-state-storage": "^0.16.0",
36
+ "@lmzhen/dsh-evolution-state": "^0.16.0"
36
37
  },
37
38
  "devDependencies": {
38
- "@lmzhen/dsh-evolution-core": "^0.15.2",
39
- "@lmzhen/dsh-evolution-state-storage": "^0.15.2",
40
- "@lmzhen/dsh-evolution-state": "^0.15.2"
39
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
40
+ "@lmzhen/dsh-evolution-core": "^0.16.0",
41
+ "@lmzhen/dsh-evolution-state-storage": "^0.16.0",
42
+ "@lmzhen/dsh-evolution-state": "^0.16.0"
41
43
  }
42
44
  }