@lmzhen/dsh-evolution-approval 0.17.1 → 0.18.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 +3 -1
- package/lib/index.js +44 -13
- package/lib/types/index.d.ts +34 -0
- package/lib/types/routes.d.ts +2 -2
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -59,6 +59,8 @@ preview, or a resolver that cannot answer (a stale anchor, an operation with no
|
|
|
59
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
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
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
|
+
|
|
62
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).
|
|
63
65
|
|
|
64
66
|
## Notes and history
|
|
@@ -68,4 +70,4 @@ preview, or a resolver that cannot answer (a stale anchor, an operation with no
|
|
|
68
70
|
would drift from what the replay writes (the write path owns its own normalization).
|
|
69
71
|
|
|
70
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.
|
|
71
|
-
- **No
|
|
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.
|
package/lib/index.js
CHANGED
|
@@ -2,9 +2,10 @@ import { Service } from "@deepseek-ai/cordis";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { elapsedSince, isLoopbackRequest, isProcessAlive, parseLockBody, readJsonObject, textDiffFacts, writeJson } from "@lmzhen/dsh-evolution-core";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
5
|
+
import { PENDING_STATUSES } from "@lmzhen/dsh-evolution-state-storage";
|
|
5
6
|
//#region lib/types/routes.js
|
|
6
7
|
/**
|
|
7
|
-
* The approval HTTP surface:
|
|
8
|
+
* The approval HTTP surface: four loopback routes over the write gate's pending window.
|
|
8
9
|
*
|
|
9
10
|
* Layering: this module maps a route to a USE CASE and does nothing else. The pending window itself
|
|
10
11
|
* (ids, statuses, claim/resolve rules, the replay runners) lives in this package's service and in
|
|
@@ -23,17 +24,7 @@ const APPROVAL_ROUTES = {
|
|
|
23
24
|
preview: "/api/dsh-evolution/approval/preview"
|
|
24
25
|
};
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
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.
|
|
27
|
+
* Build the four routes over the approval service.
|
|
37
28
|
* @param approval - the host's approval face (the service instance, or a spec fixture).
|
|
38
29
|
* @returns the route list for `ctx.webServer.register`.
|
|
39
30
|
*/
|
|
@@ -250,10 +241,14 @@ var EvolutionApproval = class extends Service {
|
|
|
250
241
|
static inject = ["evolutionState"];
|
|
251
242
|
static Config = z.object({
|
|
252
243
|
enabled: z.boolean().default(false),
|
|
253
|
-
stageForeground: z.boolean().default(true)
|
|
244
|
+
stageForeground: z.boolean().default(true),
|
|
245
|
+
pendingTtlMs: z.number().min(0).default(0)
|
|
254
246
|
});
|
|
255
247
|
enabled;
|
|
256
248
|
stageForegroundConfig;
|
|
249
|
+
/** Effective TTL in ms; 0 = the window never expires (T4-09/A51). A non-finite or negative value
|
|
250
|
+
* is clamped here because the schema only guards the loader path. */
|
|
251
|
+
pendingTtlMs;
|
|
257
252
|
/** P3 (v16): public read for staging pre-checks (learning-graph refuses a
|
|
258
253
|
* stage that no runner could replay only when foreground writes stage at
|
|
259
254
|
* all). Mirrors `this.stageForeground`. */
|
|
@@ -267,6 +262,8 @@ var EvolutionApproval = class extends Service {
|
|
|
267
262
|
super(ctx, "evolutionApproval");
|
|
268
263
|
this.enabled = config.enabled ?? false;
|
|
269
264
|
this.stageForegroundConfig = config.stageForeground ?? true;
|
|
265
|
+
const ttl = config.pendingTtlMs ?? 0;
|
|
266
|
+
this.pendingTtlMs = Number.isFinite(ttl) && ttl > 0 ? Math.floor(ttl) : 0;
|
|
270
267
|
this.ctx.inject(["webServer"], (scope) => {
|
|
271
268
|
scope.effect(() => {
|
|
272
269
|
const disposers = makeApprovalRoutes(this).map((route) => scope.webServer.register(route));
|
|
@@ -360,7 +357,41 @@ var EvolutionApproval = class extends Service {
|
|
|
360
357
|
message: "Foreground write allowed."
|
|
361
358
|
};
|
|
362
359
|
}
|
|
360
|
+
/** T4-09/A51: the effective expiry of the staged window, 0 = never. Read by the command surface
|
|
361
|
+
* and the doctor so both can say which of the three human-confirmation states is in force. */
|
|
362
|
+
get ttlMs() {
|
|
363
|
+
return this.pendingTtlMs;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Close every staged write that has been waiting longer than the TTL (T4-09/A51).
|
|
367
|
+
*
|
|
368
|
+
* The window had THREE terminal states only — approve, reject, release — all of them human, so an
|
|
369
|
+
* unattended deployment accumulated staged records for ever. Expiry is the missing third state of
|
|
370
|
+
* the human-confirmation triangle ("never pop up and just run" / "wait indefinitely" / "cancel on
|
|
371
|
+
* timeout"), and it is deliberately implemented as the reject side: the record converges through
|
|
372
|
+
* the seam's exactly-once transition to `rejected` with its `resolvedAt` stamped, and the runner
|
|
373
|
+
* is never invoked. NOTHING a human did not approve can execute; the cost of a wrong TTL is a
|
|
374
|
+
* staged write that must be staged again.
|
|
375
|
+
*
|
|
376
|
+
* A sweep, not a timer: every read of the window converges it (the CLI, the settings card and the
|
|
377
|
+
* doctor all read through {@link list}), so the family does not gain a background timer that would
|
|
378
|
+
* have to be unref'd, disposed and reasoned about for process liveness.
|
|
379
|
+
* @returns the ids that were closed by this sweep (empty when the TTL is off or nothing was stale).
|
|
380
|
+
*/
|
|
381
|
+
async expireStale() {
|
|
382
|
+
if (this.pendingTtlMs <= 0) return [];
|
|
383
|
+
const cutoff = Date.now() - this.pendingTtlMs;
|
|
384
|
+
const expired = [];
|
|
385
|
+
for (const record of await this.state().listPending("pending")) {
|
|
386
|
+
const stagedAt = Date.parse(record.createdAt);
|
|
387
|
+
if (!Number.isFinite(stagedAt) || stagedAt > cutoff) continue;
|
|
388
|
+
if ((await this.state().tryResolvePending(record.id, "rejected")).applied) expired.push(record.id);
|
|
389
|
+
}
|
|
390
|
+
if (expired.length > 0) this.ctx.logger.warn(`evolution-approval: ${expired.length} staged write(s) passed pendingTtlMs=${this.pendingTtlMs} and were closed as rejected WITHOUT executing: ${expired.join(", ")} — stage them again if they are still wanted`);
|
|
391
|
+
return expired;
|
|
392
|
+
}
|
|
363
393
|
async list(status = "pending") {
|
|
394
|
+
await this.expireStale();
|
|
364
395
|
return await this.state().listPending(status);
|
|
365
396
|
}
|
|
366
397
|
async approve(id) {
|
package/lib/types/index.d.ts
CHANGED
|
@@ -113,6 +113,10 @@ export type ApprovalLike = {
|
|
|
113
113
|
* not pre-refuse on it. */
|
|
114
114
|
stageForeground?: boolean;
|
|
115
115
|
registerRunner(kind: PendingKind, runner: WriteRunner, preview?: WritePreview): () => void;
|
|
116
|
+
/** T4-09/A51: the staged window's effective expiry in ms, 0 = never. Optional for the same reason
|
|
117
|
+
* the other reads are: an older/partial implementation simply does not answer, and a caller must not
|
|
118
|
+
* read that absence as "expires immediately". */
|
|
119
|
+
ttlMs?: number;
|
|
116
120
|
list(status?: PendingStatus): Promise<PendingRecord[]>;
|
|
117
121
|
approve(id: string): Promise<{
|
|
118
122
|
ok: boolean;
|
|
@@ -149,12 +153,22 @@ export interface Config {
|
|
|
149
153
|
enabled?: boolean;
|
|
150
154
|
/** Require approval for foreground writes as well. */
|
|
151
155
|
stageForeground?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* T4-09/A51: milliseconds after which a STAGED write expires. `0` (the default) means never —
|
|
158
|
+
* the window then behaves exactly as before. Expiry closes the record the same way a reject does
|
|
159
|
+
* (status `rejected`, `resolvedAt` stamped, runner NEVER invoked): an expiry is a cleanup of the
|
|
160
|
+
* window, never an approval, so nothing a human did not approve can execute.
|
|
161
|
+
*/
|
|
162
|
+
pendingTtlMs?: number;
|
|
152
163
|
}
|
|
153
164
|
export declare class EvolutionApproval extends Service {
|
|
154
165
|
static inject: string[];
|
|
155
166
|
static Config: Schema<Config>;
|
|
156
167
|
private readonly enabled;
|
|
157
168
|
private readonly stageForegroundConfig;
|
|
169
|
+
/** Effective TTL in ms; 0 = the window never expires (T4-09/A51). A non-finite or negative value
|
|
170
|
+
* is clamped here because the schema only guards the loader path. */
|
|
171
|
+
private readonly pendingTtlMs;
|
|
158
172
|
/** P3 (v16): public read for staging pre-checks (learning-graph refuses a
|
|
159
173
|
* stage that no runner could replay only when foreground writes stage at
|
|
160
174
|
* all). Mirrors `this.stageForeground`. */
|
|
@@ -192,6 +206,26 @@ export declare class EvolutionApproval extends Service {
|
|
|
192
206
|
}>;
|
|
193
207
|
/** Evaluate one mutation. Returns allow, or stores the write and returns staged. */
|
|
194
208
|
request(input: ApprovalRequest): Promise<ApprovalDecision>;
|
|
209
|
+
/** T4-09/A51: the effective expiry of the staged window, 0 = never. Read by the command surface
|
|
210
|
+
* and the doctor so both can say which of the three human-confirmation states is in force. */
|
|
211
|
+
get ttlMs(): number;
|
|
212
|
+
/**
|
|
213
|
+
* Close every staged write that has been waiting longer than the TTL (T4-09/A51).
|
|
214
|
+
*
|
|
215
|
+
* The window had THREE terminal states only — approve, reject, release — all of them human, so an
|
|
216
|
+
* unattended deployment accumulated staged records for ever. Expiry is the missing third state of
|
|
217
|
+
* the human-confirmation triangle ("never pop up and just run" / "wait indefinitely" / "cancel on
|
|
218
|
+
* timeout"), and it is deliberately implemented as the reject side: the record converges through
|
|
219
|
+
* the seam's exactly-once transition to `rejected` with its `resolvedAt` stamped, and the runner
|
|
220
|
+
* is never invoked. NOTHING a human did not approve can execute; the cost of a wrong TTL is a
|
|
221
|
+
* staged write that must be staged again.
|
|
222
|
+
*
|
|
223
|
+
* A sweep, not a timer: every read of the window converges it (the CLI, the settings card and the
|
|
224
|
+
* doctor all read through {@link list}), so the family does not gain a background timer that would
|
|
225
|
+
* have to be unref'd, disposed and reasoned about for process liveness.
|
|
226
|
+
* @returns the ids that were closed by this sweep (empty when the TTL is off or nothing was stale).
|
|
227
|
+
*/
|
|
228
|
+
expireStale(): Promise<string[]>;
|
|
195
229
|
list(status?: PendingStatus): Promise<PendingRecord[]>;
|
|
196
230
|
approve(id: string): Promise<{
|
|
197
231
|
ok: boolean;
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The approval HTTP surface:
|
|
2
|
+
* The approval HTTP surface: four loopback routes over the write gate's pending window.
|
|
3
3
|
*
|
|
4
4
|
* Layering: this module maps a route to a USE CASE and does nothing else. The pending window itself
|
|
5
5
|
* (ids, statuses, claim/resolve rules, the replay runners) lives in this package's service and in
|
|
@@ -53,7 +53,7 @@ export interface ApprovalFace {
|
|
|
53
53
|
previewOf(kind: PendingKind): WritePreview | undefined;
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
* Build the
|
|
56
|
+
* Build the four routes over the approval service.
|
|
57
57
|
* @param approval - the host's approval face (the service instance, or a spec fixture).
|
|
58
58
|
* @returns the route list for `ctx.webServer.register`.
|
|
59
59
|
*/
|
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.
|
|
4
|
+
"version": "0.18.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -26,19 +26,19 @@
|
|
|
26
26
|
],
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
30
|
-
"@lmzhen/dsh-evolution-core": "^0.
|
|
29
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
30
|
+
"@lmzhen/dsh-evolution-core": "^0.18.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
34
34
|
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
|
|
35
|
-
"@lmzhen/dsh-evolution-state-storage": "^0.
|
|
36
|
-
"@lmzhen/dsh-evolution-state": "^0.
|
|
35
|
+
"@lmzhen/dsh-evolution-state-storage": "^0.18.0",
|
|
36
|
+
"@lmzhen/dsh-evolution-state": "^0.18.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
|
|
40
|
-
"@lmzhen/dsh-evolution-core": "^0.
|
|
41
|
-
"@lmzhen/dsh-evolution-state-storage": "^0.
|
|
42
|
-
"@lmzhen/dsh-evolution-state": "^0.
|
|
40
|
+
"@lmzhen/dsh-evolution-core": "^0.18.0",
|
|
41
|
+
"@lmzhen/dsh-evolution-state-storage": "^0.18.0",
|
|
42
|
+
"@lmzhen/dsh-evolution-state": "^0.18.0"
|
|
43
43
|
}
|
|
44
44
|
}
|