@lmzhen/dsh-evolution-approval 0.32.4 → 0.32.6
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 +17 -63
- package/lib/index.js +45 -3
- package/lib/types/routes.d.ts +11 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -1,73 +1,27 @@
|
|
|
1
1
|
# @lmzhen/dsh-evolution-approval
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
one-shot; this service adds the Hermes staged queue: `request()` stores background writes,
|
|
5
|
-
`approve()` replays them through a runner, `reject()` discards them. The model-facing write tools
|
|
6
|
-
register their own runners (`tool-memory` via `registerRunner('memory', …)`, `tool-skill-manage`
|
|
7
|
-
via `registerRunner('skill', …)`), so staged writes are replayable only when the corresponding
|
|
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.
|
|
3
|
+
Staged approval service for memory and skill writes. It records a proposed mutation, then replays the registered runner only after an operator approves it.
|
|
11
4
|
|
|
12
|
-
|
|
5
|
+
## Operator surface
|
|
13
6
|
|
|
14
|
-
|
|
15
|
-
node node_modules/vitest/vitest.mjs run packages/evolution-approval/tests # overlay: packages/evolution/evolution-approval/tests
|
|
16
|
-
```
|
|
7
|
+
Use `/evolution pending`, `approve <id>`, and `reject <id>`. The settings UI can show the pending queue and, when the owning tool registered a preview, a before/after diff without executing the write.
|
|
17
8
|
|
|
18
|
-
|
|
9
|
+
`/evolution release <id>` is different from approval. It returns an orphaned `executing` record to `pending` after a crashed approval. A live foreign process or an in-flight local decision blocks release.
|
|
19
10
|
|
|
20
|
-
|
|
21
|
-
- **Prompt prefix / KV cache:** unchanged by this package: family-level rules single-sourced in `packages/README.md` §"Model-visible prompt prefix and the KV cache".
|
|
22
|
-
- **Mount it?** yes — the `evolution-approval` row, in `evolution-host`/`evolution-all`/one-click `evolution-preset` (`enabled: false` there).
|
|
11
|
+
## Defaults
|
|
23
12
|
|
|
24
|
-
|
|
13
|
+
| Setting | Default | Effect |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `enabled` | `false` | Writes proceed without this staged queue. |
|
|
16
|
+
| `stageForeground` | `true` | Once enabled, foreground writes also stage unless the effective session policy is `never`. |
|
|
17
|
+
| `pendingTtlMs` | `0` | Pending records never expire automatically. |
|
|
18
|
+
| `approvalConfirm` | `auto` | Approving does not add a second confirmation by default. |
|
|
25
19
|
|
|
26
|
-
|
|
27
|
-
- `stageForeground` (default `true`): stage foreground writes too.
|
|
20
|
+
A positive `pendingTtlMs` closes expired records as `rejected` and never executes them. Expiry is evaluated on queue reads and decisions, not by a background timer.
|
|
28
21
|
|
|
29
|
-
##
|
|
22
|
+
## Boundaries
|
|
30
23
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
49
|
-
## Known limitations
|
|
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}`.
|
|
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.
|
|
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.
|
|
61
|
-
|
|
62
|
-
- **The HTTP face is loopback-only by design.** The fence is the family's shared loopback check, authoritative on the socket (`X-Forwarded-For` is never trusted), so a deployment that opens the host to a LAN — where the platform's own `/api` answers normally — gets **403** on all four routes from any non-loopback client. Reach the host over loopback (a local browser, or an SSH tunnel). Whether the family should follow the platform's `trustedHosts` instead is an open decision (v46 `决策 3`; the mechanism lives in `evolution-core`'s `http-routes.ts`).
|
|
63
|
-
|
|
64
|
-
**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).
|
|
65
|
-
|
|
66
|
-
## Notes and history
|
|
67
|
-
|
|
68
|
-
- **The HTTP face + preview seam (G3, 2026-09-30).** The routes exist so a reader can act on the window
|
|
69
|
-
without the console; the preview is registered BESIDE the runner because a preview derived anywhere else
|
|
70
|
-
would drift from what the replay writes (the write path owns its own normalization).
|
|
71
|
-
|
|
72
|
-
- **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.
|
|
73
|
-
- **No freshness re-validation for kinds WITHOUT an anchor (F-328, corrected).** The staged record stores the `args` snapshot captured at request time and replays exactly those args. Kinds that carry no anchor — **`memory` today** — replay that snapshot as-is, so a target that changed after staging is not re-checked. A skill write is NOT in that class: it carries `staged_from_sha256`, so its replay and its preview refuse a changed target (the per-kind rule above). The pending surface (`/evolution pending --detail`) exposes the staged `args` so you can review what will actually be replayed before approving.
|
|
24
|
+
- Background-review writes require a registered replay runner. Staging a record without one is not a usable approval flow.
|
|
25
|
+
- Curator and merge writes have their own controls; they do not pass through this ordinary memory/skill approval queue.
|
|
26
|
+
- Before using `release`, verify whether the original runner already landed the write and what effect it had. Approving the returned record replays that operation, which may be non-idempotent.
|
|
27
|
+
- Resolved history is bounded by the state provider. The JSON provider archives older resolved records; the domain provider does not create that JSON archive sidecar.
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { clampOnce, elapsedSince, registerOwnerSettings, textDiffFacts, unwrapLive } from "@lmzhen/dsh-evolution-core";
|
|
1
2
|
import { Service } from "@deepseek-ai/cordis";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { clampOnce, elapsedSince, textDiffFacts, unwrapLive } from "@lmzhen/dsh-evolution-core";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
5
5
|
import { PENDING_STATUSES } from "@lmzhen/dsh-evolution-state-storage";
|
|
6
6
|
import { HOST_ROUTES, json, methodNotAllowed, readJsonObject, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
|
|
@@ -31,6 +31,26 @@ const APPROVAL_ROUTES = HOST_ROUTES.approval;
|
|
|
31
31
|
* reported the same way rather than escaping as an empty 500, because the operator's next step
|
|
32
32
|
* ("reject it", "retry") is only actionable if the reason is visible.
|
|
33
33
|
*/
|
|
34
|
+
function structuredOf(record) {
|
|
35
|
+
const args = record.args;
|
|
36
|
+
if (record.kind === "memory") {
|
|
37
|
+
const operations = Array.isArray(args?.operations) ? args.operations : [];
|
|
38
|
+
const first = operations[0];
|
|
39
|
+
return {
|
|
40
|
+
verb: typeof first?.action === "string" ? first.action : typeof args?.action === "string" ? args.action : "update",
|
|
41
|
+
target: typeof args?.target === "string" ? args.target : "memory",
|
|
42
|
+
operationCount: operations.length === 0 ? 1 : operations.length,
|
|
43
|
+
actions: record.status === "executing" ? ["release"] : ["approve", "reject"]
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const operation = args?.operation;
|
|
47
|
+
return {
|
|
48
|
+
verb: typeof operation?.action === "string" ? operation.action : "update",
|
|
49
|
+
target: typeof operation?.name === "string" ? operation.name : record.summary,
|
|
50
|
+
operationCount: 1,
|
|
51
|
+
actions: record.status === "executing" ? ["release"] : ["approve", "reject"]
|
|
52
|
+
};
|
|
53
|
+
}
|
|
34
54
|
async function decide(run, id) {
|
|
35
55
|
try {
|
|
36
56
|
const result = await run(id);
|
|
@@ -85,7 +105,8 @@ function makeApprovalRoutes(approval, audit) {
|
|
|
85
105
|
createdAt: record.createdAt,
|
|
86
106
|
age: elapsedSince(record.createdAt, Date.now()),
|
|
87
107
|
status: record.status,
|
|
88
|
-
...record.claimedBy === void 0 ? {} : { claimedBy: record.claimedBy }
|
|
108
|
+
...record.claimedBy === void 0 ? {} : { claimedBy: record.claimedBy },
|
|
109
|
+
...structuredOf(record)
|
|
89
110
|
}))
|
|
90
111
|
});
|
|
91
112
|
} catch (error) {
|
|
@@ -165,6 +186,26 @@ function makeApprovalRoutes(approval, audit) {
|
|
|
165
186
|
return await decide((current) => approval.approve(current, audit), id);
|
|
166
187
|
}
|
|
167
188
|
},
|
|
189
|
+
{
|
|
190
|
+
path: APPROVAL_ROUTES.release,
|
|
191
|
+
methods: ["POST"],
|
|
192
|
+
requestBody: "buffered",
|
|
193
|
+
fetch: async (request) => {
|
|
194
|
+
if (request.method !== "POST") return methodNotAllowed(["POST"]);
|
|
195
|
+
const id = idOf(await readJsonObject(request));
|
|
196
|
+
if (id === "") return json(400, {
|
|
197
|
+
ok: false,
|
|
198
|
+
code: "bad-request",
|
|
199
|
+
message: "缺少 id"
|
|
200
|
+
});
|
|
201
|
+
if (approval.release === void 0) return json(200, {
|
|
202
|
+
ok: false,
|
|
203
|
+
code: "approval-refused",
|
|
204
|
+
message: "这个部署没有提供 release"
|
|
205
|
+
});
|
|
206
|
+
return await decide((current) => approval.release(current), id);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
168
209
|
{
|
|
169
210
|
path: APPROVAL_ROUTES.reject,
|
|
170
211
|
methods: ["POST"],
|
|
@@ -235,7 +276,7 @@ function effectiveSessionPolicy(ctx, session) {
|
|
|
235
276
|
if (!approval || session === void 0) return void 0;
|
|
236
277
|
return approval.overrideOf(session) ?? approval.config?.policy ?? "ask";
|
|
237
278
|
}
|
|
238
|
-
var EvolutionApproval = class extends Service {
|
|
279
|
+
var EvolutionApproval = class EvolutionApproval extends Service {
|
|
239
280
|
static inject = ["evolutionState"];
|
|
240
281
|
static Config = z.object({
|
|
241
282
|
enabled: z.boolean().default(false).volatile(),
|
|
@@ -265,6 +306,7 @@ var EvolutionApproval = class extends Service {
|
|
|
265
306
|
inFlight = /* @__PURE__ */ new Map();
|
|
266
307
|
constructor(ctx, config = {}) {
|
|
267
308
|
super(ctx, "evolutionApproval");
|
|
309
|
+
registerOwnerSettings(this.ctx, "evolution-approval", EvolutionApproval.Config);
|
|
268
310
|
this.enabledConfig = config.enabled;
|
|
269
311
|
this.stageForegroundConfig = config.stageForeground ?? true;
|
|
270
312
|
this.pendingTtlMsConfig = config.pendingTtlMs;
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export declare const APPROVAL_ROUTES: {
|
|
|
24
24
|
readonly preview: "/api/dsh-evolution/approval/preview";
|
|
25
25
|
readonly approve: "/api/dsh-evolution/approval/approve";
|
|
26
26
|
readonly reject: "/api/dsh-evolution/approval/reject";
|
|
27
|
+
readonly release: "/api/dsh-evolution/approval/release";
|
|
27
28
|
};
|
|
28
29
|
/** What one row of the pending list carries to the browser half. */
|
|
29
30
|
export interface PendingRow {
|
|
@@ -36,6 +37,11 @@ export interface PendingRow {
|
|
|
36
37
|
readonly status: PendingStatus;
|
|
37
38
|
/** Present on a claimed-but-unfinished approve: the operator clears it by rejecting (doctor says so too). */
|
|
38
39
|
readonly claimedBy?: string | undefined;
|
|
40
|
+
/** Structured summary derived from the staged args; the raw summary stays for details. */
|
|
41
|
+
readonly verb: string;
|
|
42
|
+
readonly target: string;
|
|
43
|
+
readonly operationCount: number;
|
|
44
|
+
readonly actions: readonly string[];
|
|
39
45
|
}
|
|
40
46
|
/**
|
|
41
47
|
* The slice of the approval service these routes read.
|
|
@@ -54,6 +60,11 @@ export interface ApprovalFace {
|
|
|
54
60
|
ok: boolean;
|
|
55
61
|
message: string;
|
|
56
62
|
}>;
|
|
63
|
+
/** Release an executing claim; absent on fixtures that only model approve/reject. */
|
|
64
|
+
release?(id: string): Promise<{
|
|
65
|
+
ok: boolean;
|
|
66
|
+
message: string;
|
|
67
|
+
}>;
|
|
57
68
|
/** The preview the replayer of that kind registered, when it registered one. */
|
|
58
69
|
previewOf(kind: PendingKind): WritePreview | undefined;
|
|
59
70
|
}
|
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.32.
|
|
4
|
+
"version": "0.32.6",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -27,18 +27,18 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@deepseek-ai/schemastery": "^3.18.5",
|
|
30
|
-
"@lmzhen/dsh-evolution-core": "^0.32.
|
|
30
|
+
"@lmzhen/dsh-evolution-core": "^0.32.6"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@deepseek-ai/cordis": "^4.0.5",
|
|
34
|
-
"@lmzhen/dsh-evolution-core": "^0.32.
|
|
35
|
-
"@lmzhen/dsh-evolution-state": "^0.32.
|
|
36
|
-
"@lmzhen/dsh-evolution-state-storage": "^0.32.
|
|
34
|
+
"@lmzhen/dsh-evolution-core": "^0.32.6",
|
|
35
|
+
"@lmzhen/dsh-evolution-state": "^0.32.6",
|
|
36
|
+
"@lmzhen/dsh-evolution-state-storage": "^0.32.6"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@lmzhen/dsh-evolution-core": "^0.32.
|
|
40
|
-
"@lmzhen/dsh-evolution-state": "^0.32.
|
|
41
|
-
"@lmzhen/dsh-evolution-state-storage": "^0.32.
|
|
39
|
+
"@lmzhen/dsh-evolution-core": "^0.32.6",
|
|
40
|
+
"@lmzhen/dsh-evolution-state": "^0.32.6",
|
|
41
|
+
"@lmzhen/dsh-evolution-state-storage": "^0.32.6"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"dsh": ">=0.2.1-rc.2 <0.2.2-0"
|