@sema-agent/server 7.55.0 → 7.57.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/MIGRATION.md +3 -3
- package/README.md +14 -7
- package/README.zh-CN.md +10 -6
- package/USAGE.md +207 -59
- package/deploy/sema-up/chart/values.yaml +1 -1
- package/dist/approval-ask-audit-store.d.ts +100 -1
- package/dist/approval-ask-audit-store.js +103 -2
- package/dist/approval-card.d.ts +148 -13
- package/dist/approval-card.js +82 -13
- package/dist/approval-content-kind.d.ts +22 -0
- package/dist/approval-content-kind.js +5 -0
- package/dist/approval.d.ts +31 -0
- package/dist/approval.js +18 -0
- package/dist/auto-mode-face.d.ts +111 -0
- package/dist/auto-mode-face.js +99 -0
- package/dist/bench/s1/arms.js +5 -5
- package/dist/bench/s1/live-deps.js +11 -11
- package/dist/bench/s1/run-firm.js +3 -0
- package/dist/bench/s1/runner-ctx.d.ts +4 -0
- package/dist/bench/s1/runner-ctx.js +7 -0
- package/dist/boot/config-center.js +23 -1
- package/dist/boot/coordinators.js +13 -1
- package/dist/boot/leader.d.ts +21 -0
- package/dist/boot/leader.js +6 -0
- package/dist/boot/parked-revive-gate.d.ts +7 -2
- package/dist/boot/parked-revive-gate.js +12 -1
- package/dist/boot/resolve-spec.d.ts +3 -0
- package/dist/boot/resolve-spec.js +3 -1
- package/dist/boot/runner-deps.d.ts +12 -0
- package/dist/boot/runner-deps.js +28 -3
- package/dist/boot/runtime-caps.d.ts +19 -8
- package/dist/boot/runtime-caps.js +50 -20
- package/dist/boot/session-shell-gate-registry.d.ts +39 -0
- package/dist/boot/session-shell-gate-registry.js +21 -0
- package/dist/config-catalog.js +14 -5
- package/dist/config-center/types.d.ts +2 -1
- package/dist/config-provider.js +1 -0
- package/dist/config-types.d.ts +41 -10
- package/dist/config.d.ts +21 -0
- package/dist/config.js +40 -5
- package/dist/http/route-ctx.d.ts +90 -3
- package/dist/http/routes/a2a-serve.js +5 -3
- package/dist/http/routes/approvals-assistant.js +21 -8
- package/dist/http/routes/capabilities.js +21 -4
- package/dist/http/routes/leader.js +2 -2
- package/dist/http/routes/memory-origin.d.ts +2 -2
- package/dist/http/routes/rules.js +3 -2
- package/dist/http/routes/runs.d.ts +0 -14
- package/dist/http/routes/runs.js +20 -8
- package/dist/http/routes/tasks.js +34 -8
- package/dist/http/routes/workflows.js +20 -5
- package/dist/http/server.d.ts +11 -1
- package/dist/http/server.js +220 -34
- package/dist/http/wire-types.d.ts +9 -4
- package/dist/leader/diffout.js +2 -1
- package/dist/leader/endpoint.js +49 -15
- package/dist/leader/fanout.js +6 -5
- package/dist/leader/leader.js +17 -14
- package/dist/leader/merge.js +17 -12
- package/dist/leader/planner.js +3 -2
- package/dist/leader/repair-oracle.js +6 -4
- package/dist/leader/repair-wire.js +5 -3
- package/dist/leader/wire.d.ts +80 -1
- package/dist/leader/wire.js +55 -26
- package/dist/main.js +44 -8
- package/dist/observability/err-text.d.ts +6 -0
- package/dist/observability/err-text.js +10 -0
- package/dist/observability/fail-open.d.ts +40 -0
- package/dist/observability/fail-open.js +19 -0
- package/dist/observability/metrics.js +1 -1
- package/dist/observability/run-terminal-log.d.ts +120 -0
- package/dist/observability/run-terminal-log.js +360 -0
- package/dist/orchestration/workflow-completion-inbox.d.ts +9 -0
- package/dist/orchestration/workflow-completion-inbox.js +8 -0
- package/dist/permission-rule-vocab.d.ts +40 -0
- package/dist/permission-rule-vocab.js +17 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +26 -0
- package/dist/plugins/checkpoint-store-sql.js +23 -4
- package/dist/plugins/file-run-store.d.ts +3 -34
- package/dist/plugins/file-run-store.js +21 -0
- package/dist/plugins/local-checkpoint-store.d.ts +10 -0
- package/dist/plugins/local-checkpoint-store.js +5 -0
- package/dist/plugins/local-session-store.d.ts +9 -9
- package/dist/plugins/local-session-store.js +5 -3
- package/dist/plugins/memory-run-store.d.ts +3 -15
- package/dist/plugins/memory-run-store.js +21 -0
- package/dist/plugins/permission-rule-store-file.d.ts +22 -4
- package/dist/plugins/permission-rule-store-file.js +25 -11
- package/dist/plugins/permission-rule-store-sql.d.ts +60 -30
- package/dist/plugins/permission-rule-store-sql.js +39 -23
- package/dist/plugins/pg-session-storage.js +17 -13
- package/dist/plugins/remote-scratchpad.js +3 -2
- package/dist/plugins/run-store-sql.d.ts +3 -42
- package/dist/plugins/run-store-sql.js +36 -5
- package/dist/plugins/store-backend.d.ts +1 -1
- package/dist/plugins/store-contracts.d.ts +70 -1
- package/dist/plugins/tidb-session-store.js +18 -14
- package/dist/plugins/workflow-run-store-sql.d.ts +5 -0
- package/dist/plugins/workflow-run-store-sql.js +10 -2
- package/dist/rules-consent.d.ts +5 -4
- package/dist/rules-consent.js +25 -46
- package/dist/run-cancel-context.d.ts +13 -0
- package/dist/run-cancel-context.js +15 -0
- package/dist/run-local.js +2 -2
- package/dist/runs.d.ts +4 -1
- package/dist/runs.js +41 -10
- package/dist/runtime-caps-resolver.d.ts +133 -17
- package/dist/runtime-caps-resolver.js +44 -4
- package/dist/security.d.ts +7 -0
- package/dist/task-settings.d.ts +57 -3
- package/dist/task-settings.js +78 -5
- package/dist/task-workflow.d.ts +32 -2
- package/dist/task-workflow.js +8 -3
- package/dist/tool-approval.d.ts +38 -63
- package/dist/tool-approval.js +102 -74
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/ledger-sink.d.ts +13 -4
- package/dist/trace/ledger-sink.js +14 -6
- package/dist/trace/project.d.ts +13 -0
- package/dist/trace/project.js +10 -1
- package/dist/trace/redact.d.ts +22 -11
- package/dist/trace/redact.js +655 -20
- package/dist/trace/sema-provenance.d.ts +26 -0
- package/dist/trace/sema-provenance.js +11 -0
- package/dist/turn-activity.d.ts +32 -2
- package/dist/turn-activity.js +29 -4
- package/package.json +3 -3
- package/skills/find-skills.md +1 -1
- package/skills/loop.md +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { contentKindOf } from "../approval-content-kind.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
|
|
3
4
|
import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
|
|
4
5
|
import { redactDeep, redactSecrets } from "../trace/redact.js";
|
|
5
|
-
import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema } from "../approval-card.js";
|
|
6
|
+
import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
|
|
6
7
|
import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
|
|
7
8
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
8
9
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
@@ -68,7 +69,7 @@ export function boundedRuleOffers(raw, opts = {}) {
|
|
|
68
69
|
for (const item of raw.slice(0, MAX_RULE_OFFER_SCAN)) {
|
|
69
70
|
if (out.length >= MAX_RULE_OFFERS)
|
|
70
71
|
break;
|
|
71
|
-
const parsed = RuleOfferRawSchema.safeParse(preboundBatchMembers(item));
|
|
72
|
+
const parsed = RuleOfferRawSchema.safeParse(screenUncoveredDetailSeat(backfillLegacyBatchMemberKind(preboundBatchMembers(item))));
|
|
72
73
|
if (!parsed.success)
|
|
73
74
|
continue;
|
|
74
75
|
if (parsed.data.kind === "single") {
|
|
@@ -77,8 +78,18 @@ export function boundedRuleOffers(raw, opts = {}) {
|
|
|
77
78
|
else {
|
|
78
79
|
out.push({
|
|
79
80
|
kind: "batch",
|
|
80
|
-
rules: parsed.data.rules.slice(0, MAX_RULE_OFFER_BATCH_MEMBERS).map((r) =>
|
|
81
|
+
rules: parsed.data.rules.slice(0, MAX_RULE_OFFER_BATCH_MEMBERS).map((r) => {
|
|
82
|
+
switch (r.kind) {
|
|
83
|
+
case "command":
|
|
84
|
+
return { kind: "command", rule: scrub(r.rule), match: r.match, command: scrub(r.command), segment: scrub(r.segment) };
|
|
85
|
+
case "directoryRead":
|
|
86
|
+
return { kind: "directoryRead", rule: scrub(r.rule), directory: scrub(r.directory), segment: scrub(r.segment) };
|
|
87
|
+
default:
|
|
88
|
+
throw new Error(`unknown rule-offer batch member kind: ${JSON.stringify(r)}`);
|
|
89
|
+
}
|
|
90
|
+
}),
|
|
81
91
|
uncoveredSegments: parsed.data.uncoveredSegments,
|
|
92
|
+
...(parsed.data.uncoveredDetail !== undefined ? { uncoveredDetail: parsed.data.uncoveredDetail.map((d) => ({ segment: scrub(d.segment), reason: d.reason })) } : {}),
|
|
82
93
|
});
|
|
83
94
|
}
|
|
84
95
|
}
|
|
@@ -437,6 +448,7 @@ export class SqlCheckpointStore {
|
|
|
437
448
|
? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
|
|
438
449
|
: await this.db.query(`${base} ORDER BY c.created_at_ms ASC`);
|
|
439
450
|
const out = rows.map((r) => {
|
|
451
|
+
const toolName = r.tool_name ?? null;
|
|
440
452
|
const toolCallId = r.tool_call_id ?? null;
|
|
441
453
|
const boundInputHash = r.bound_input_hash ?? null;
|
|
442
454
|
const gateKind = r.gate_kind ?? null;
|
|
@@ -444,7 +456,7 @@ export class SqlCheckpointStore {
|
|
|
444
456
|
return {
|
|
445
457
|
sessionId: String(r.session_id),
|
|
446
458
|
scope: String(r.scope),
|
|
447
|
-
toolName
|
|
459
|
+
toolName,
|
|
448
460
|
toolCallId,
|
|
449
461
|
...(toolCallId !== null ? { boundCallId: toolCallId } : {}),
|
|
450
462
|
...(boundInputHash !== null ? { boundInputHash } : {}),
|
|
@@ -456,6 +468,7 @@ export class SqlCheckpointStore {
|
|
|
456
468
|
riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
|
|
457
469
|
...(ruleOffers !== undefined ? { ruleOffers } : {}),
|
|
458
470
|
...(Number(r.has_bidi_controls) === 1 ? { hasBidiControls: true } : {}),
|
|
471
|
+
...(contentKindOf(toolName) !== undefined ? { contentKind: "content_ask" } : {}),
|
|
459
472
|
};
|
|
460
473
|
});
|
|
461
474
|
return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
|
|
@@ -536,6 +549,12 @@ export class SqlCheckpointStore {
|
|
|
536
549
|
};
|
|
537
550
|
});
|
|
538
551
|
}
|
|
552
|
+
async peekScopeByToken(token) {
|
|
553
|
+
const { rows } = await this.db.query(this.q("SELECT scope FROM checkpoint WHERE token = ?", "SELECT scope FROM checkpoint WHERE token = $1"), [token]);
|
|
554
|
+
if (!rows[0])
|
|
555
|
+
return undefined;
|
|
556
|
+
return rows[0].scope ?? null;
|
|
557
|
+
}
|
|
539
558
|
async peekPendingScope(sessionId, binding) {
|
|
540
559
|
const { sql, params } = this.pendingRowQuery("scope", sessionId, undefined, binding);
|
|
541
560
|
const { rows } = await this.db.query(sql, params);
|
|
@@ -1,37 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File-backed async run registry + replayable event log — the DURABLE local twin of {@link MemoryRunStore} (and the
|
|
3
|
-
* file-backed sibling of {@link TiDBRunStore}/{@link PgRunStore}). P0.5 variant-1 (clay 2026-06-26): the file-backed
|
|
4
|
-
* follow-on the Memory twin's header promised — a locally-run HTTP service now keeps its runs ledger + trace event log
|
|
5
|
-
* across a process restart, not just in memory. Behaviour is byte-identical to MemoryRunStore (the shared parity oracle
|
|
6
|
-
* — same single-active claim, the suspend/needs_review parks that KEEP the claim, the markResuming CAS that resets
|
|
7
|
-
* cancel/preempt, the owner null-safe guard, the keyset list order, the (taskId,seq) event dedup, and the structuredClone
|
|
8
|
-
* write/read isolation); the only difference is durability.
|
|
9
|
-
*
|
|
10
|
-
* THREE on-disk data planes (all under `<root>/runs/`), each using core's crash-safe fs primitives — never hand-rolled
|
|
11
|
-
* fs (zero drift, no zero-length/torn corruption):
|
|
12
|
-
* 1. run registry — `<root>/runs/<taskId>/run.json` = the RunRow, replaced WHOLE on every status change via
|
|
13
|
-
* `atomicWriteFile` (tmp→fsync→rename→fsync-dir, the 5-step atomic replace).
|
|
14
|
-
* 2. single-active claim — `<root>/runs/active/<sanitizePathComponent(sessionId)>` carrying the active taskId, created
|
|
15
|
-
* with `writeThenLink` (atomic create; EEXIST ⇒ the session already has an active run → the
|
|
16
|
-
* createRun loses the claim, returns `{ok:false, activeTaskId}`). The file version of task_active's
|
|
17
|
-
* unique key.
|
|
18
|
-
* 3. per-run event log — `<root>/runs/<taskId>/events.jsonl` = an `AppendLog` (one line per event, fsync:false — events
|
|
19
|
-
* are high-frequency, the torn-tail-drop on replay covers a crash tail; only the run.json status
|
|
20
|
-
* commit + the active-claim create use fsync). (taskId,seq) is deduped on replay.
|
|
21
|
-
*
|
|
22
|
-
* An in-memory INDEX (the same Maps MemoryRunStore holds) is hydrated on construction by scanning each runs/<taskId>/
|
|
23
|
-
* run.json + the active claim files — so listRuns/listSessions/getActiveTaskId are O(in-mem) with byte-identical keyset sort to the SQL/Memory
|
|
24
|
-
* twins. Every write touches BOTH the disk and the index on one path. Single-process (the FileStorageBackend boot lock
|
|
25
|
-
* guarantees one writer per data dir), so there is no per-op lock — with ONE exception: [ref]'s claim acquisition
|
|
26
|
-
* (`withClaimLock`) runs under a per-session in-process critical section, because judging a claim stale involves
|
|
27
|
-
* awaits (the checkpoint probe) and the claim + run row must commit as one unit (the SQL twin's transaction). The
|
|
28
|
-
* checkpoint-coupled suspended-run reapers run
|
|
29
|
-
* off an injected {@link RunStoreCheckpointProbe} ([ref]; wired by the local backend where checkpoint + run store
|
|
30
|
-
* share one root) and degrade to honest no-ops when the probe is absent — exactly like MemoryRunStore.
|
|
31
|
-
*/
|
|
32
1
|
import { type UsageRow } from "../usage-analytics.js";
|
|
33
|
-
import type {
|
|
34
|
-
import type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
|
|
2
|
+
import type { TaskStatus } from "@sema-agent/core";
|
|
3
|
+
import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
|
|
35
4
|
import type { RunStoreCheckpointProbe } from "./memory-run-store.js";
|
|
36
5
|
import type { LedgerEventType } from "../trace/ledger-events.js";
|
|
37
6
|
/**
|
|
@@ -136,7 +105,7 @@ export declare class FileRunStore {
|
|
|
136
105
|
getEvents(taskId: string, afterSeq: number): Promise<RunEvent[]>;
|
|
137
106
|
/** [ref] FIRST terminal writer wins(SQL 孪生同款正向 CAS on running/suspended/needs_review,
|
|
138
107
|
* [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */
|
|
139
|
-
setTerminal(taskId: string, status: TaskStatus, result:
|
|
108
|
+
setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise<void>;
|
|
140
109
|
/** Durable F4: park NON-terminal `suspended`, KEEP the task_active claim. CAS on running/suspended (reaper-revert guard). */
|
|
141
110
|
setSuspended(taskId: string): Promise<void>;
|
|
142
111
|
/** [ref] D-B: park NON-terminal `needs_review`, KEEP the claim. CAS on running/needs_review. */
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { redactErrorMessage, redactLedgerEventData, redactTerminalResult } from "../observability/run-terminal-log.js";
|
|
1
2
|
import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
4
5
|
import { randomBytes } from "node:crypto";
|
|
5
6
|
import { AppendLog, atomicWriteFile, ensureDir, readJsonlRecords, sanitizePathComponent, writeThenLink } from "@sema-agent/core";
|
|
6
7
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
8
|
+
import { notifyRunTerminal } from "../observability/run-terminal-log.js";
|
|
7
9
|
const ownerEq = (a, b) => (a ?? null) === (b ?? null);
|
|
8
10
|
const PROCESS_FACTS = {
|
|
9
11
|
pid: process.pid,
|
|
@@ -338,6 +340,7 @@ export class FileRunStore {
|
|
|
338
340
|
}
|
|
339
341
|
}
|
|
340
342
|
async appendEvent(taskId, seq, type, data) {
|
|
343
|
+
data = redactLedgerEventData(type, data);
|
|
341
344
|
const list = this.events.get(taskId) ?? [];
|
|
342
345
|
if (list.some((e) => e.seq === seq))
|
|
343
346
|
return;
|
|
@@ -363,6 +366,8 @@ export class FileRunStore {
|
|
|
363
366
|
.map((e) => ({ ...e, data: e.data == null ? null : structuredClone(e.data) }));
|
|
364
367
|
}
|
|
365
368
|
async setTerminal(taskId, status, result, error) {
|
|
369
|
+
result = redactTerminalResult(result);
|
|
370
|
+
error = error === null ? null : (redactErrorMessage(error) ?? null);
|
|
366
371
|
const r = this.runs.get(taskId);
|
|
367
372
|
if (!r) {
|
|
368
373
|
for (const [sessionId, tid] of this.active)
|
|
@@ -372,6 +377,7 @@ export class FileRunStore {
|
|
|
372
377
|
}
|
|
373
378
|
return;
|
|
374
379
|
}
|
|
380
|
+
let pending;
|
|
375
381
|
if (r.status === "running" || r.status === "suspended" || r.status === "needs_review") {
|
|
376
382
|
r.status = status;
|
|
377
383
|
r.result = result;
|
|
@@ -379,9 +385,22 @@ export class FileRunStore {
|
|
|
379
385
|
r.errorCode = result?.errorCode ?? null;
|
|
380
386
|
r.updatedAt = new Date();
|
|
381
387
|
this.persist(r);
|
|
388
|
+
pending = {
|
|
389
|
+
runId: taskId,
|
|
390
|
+
sessionId: r.sessionId,
|
|
391
|
+
owner: r.owner,
|
|
392
|
+
status,
|
|
393
|
+
resultTaskId: result?.taskId,
|
|
394
|
+
model: result?.model,
|
|
395
|
+
errorCode: r.errorCode ?? undefined,
|
|
396
|
+
errorMessage: result?.errorMessage ?? error ?? undefined,
|
|
397
|
+
elapsedMs: Math.max(0, Date.now() - r.createdAt.getTime()),
|
|
398
|
+
};
|
|
382
399
|
}
|
|
383
400
|
if (this.active.get(r.sessionId) === taskId)
|
|
384
401
|
this.releaseClaim(r.sessionId);
|
|
402
|
+
if (pending)
|
|
403
|
+
notifyRunTerminal(pending);
|
|
385
404
|
}
|
|
386
405
|
async setSuspended(taskId) {
|
|
387
406
|
const r = this.runs.get(taskId);
|
|
@@ -453,6 +472,7 @@ export class FileRunStore {
|
|
|
453
472
|
continue;
|
|
454
473
|
const sorted = [...arr].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0));
|
|
455
474
|
const latest = sorted[0];
|
|
475
|
+
const latestRunId = latest.result?.runId;
|
|
456
476
|
const times = arr.map((r) => r.createdAt.getTime());
|
|
457
477
|
const last = Math.max(...times);
|
|
458
478
|
const first = Math.min(...times);
|
|
@@ -465,6 +485,7 @@ export class FileRunStore {
|
|
|
465
485
|
objectivePreview: latest.objectivePreview,
|
|
466
486
|
lastStatus: latest.status,
|
|
467
487
|
lastRunId: latest.taskId,
|
|
488
|
+
...(typeof latestRunId === "string" && latestRunId !== "" ? { lastTaskRunId: latestRunId } : {}),
|
|
468
489
|
title: null,
|
|
469
490
|
});
|
|
470
491
|
}
|
|
@@ -112,6 +112,16 @@ export declare class LocalCheckpointStore {
|
|
|
112
112
|
* 投了就是「SQL 车道有、local 车道没有」的键集分岔。
|
|
113
113
|
*/
|
|
114
114
|
findDecidedApprovalsForBinding(sessionId: string, boundCallId: string): Promise<DecidedApprovalRecord[]>;
|
|
115
|
+
/**
|
|
116
|
+
* [ref]:按 token 的 scope 读 —— 与 SQL 孪生**同一条契约**(`undefined` 无行 / `null` 匿名 / 属主串),
|
|
117
|
+
* 语义与存在理由的唯一属主 = SQL 侧 `peekScopeByToken` 顶注。
|
|
118
|
+
*
|
|
119
|
+
* ⚠️ 形差如实记:SQL 孪生那口是真「轻读」(只 SELECT scope 一列),本形没有列可选 —— core 的 File 店
|
|
120
|
+
* 只给 `get(token)`,所以这里读的是**整行**。后果对本形无害:本形的 `get()` 没有行级版本闸(唯一的
|
|
121
|
+
* 读不动形是账本重放级 = 整店同时读不动,`findPendingTokenBySession` 先抛),所以「行体读得出而 scope
|
|
122
|
+
* 读不出」这一支在本形结构上不存在。哪天本形补了行级闸,这里要跟着改成不碰行体的读。
|
|
123
|
+
*/
|
|
124
|
+
peekScopeByToken(token: CheckpointToken): Promise<string | null | undefined>;
|
|
115
125
|
/** `undefined` = no pending row; else the owner scope (local scopes are always strings — never null).
|
|
116
126
|
* [ref]:与 `findPendingTokenBySession` 同一个 {@link pickPendingRow}(同 binding ⇒ 同一行)。 */
|
|
117
127
|
peekPendingScope(sessionId: string, binding?: PendingRowBinding): Promise<string | null | undefined>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contentKindOf } from "../approval-content-kind.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -160,6 +161,7 @@ export class LocalCheckpointStore {
|
|
|
160
161
|
return rs !== undefined ? { ruleOffers: rs } : {};
|
|
161
162
|
})(),
|
|
162
163
|
...(pa?.hasBidiControls === true ? { hasBidiControls: true } : {}),
|
|
164
|
+
...(contentKindOf(pa?.toolName ?? null) !== undefined ? { contentKind: "content_ask" } : {}),
|
|
163
165
|
});
|
|
164
166
|
}
|
|
165
167
|
return rows.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
|
|
@@ -229,6 +231,9 @@ export class LocalCheckpointStore {
|
|
|
229
231
|
}
|
|
230
232
|
return out;
|
|
231
233
|
}
|
|
234
|
+
async peekScopeByToken(token) {
|
|
235
|
+
return (await this.inner.get(token))?.scope;
|
|
236
|
+
}
|
|
232
237
|
async peekPendingScope(sessionId, binding) {
|
|
233
238
|
return this.pickPendingRow(await this.pendings(), sessionId, binding)?.cp.scope;
|
|
234
239
|
}
|
|
@@ -27,6 +27,7 @@ export declare class LocalSessionStore implements SessionStore {
|
|
|
27
27
|
* has only TERMINAL runs (no live tail to re-attach), so `lastRunId` falls back to null and the shell uses its
|
|
28
28
|
* no-anchor path — same posture as the other local degrades here (runCount:0, lastStatus:""). Persisting it would
|
|
29
29
|
* need a sidecar-format change (the #10 clobber area); deferred until a real local-file-backed need. */
|
|
30
|
+
/** K-5c/[ref]/[ref]:per-session 最近一次 run 的 {taskId, runId?}(noteTaskRun 席;runId omit ⇒ 清座)。 */
|
|
30
31
|
private readonly lastTaskRun;
|
|
31
32
|
/** 2c P1d-β — live staged-import handles keyed by stagingId, so a local staging's in-memory buffer is readable via
|
|
32
33
|
* readStagedEntries (the durable twins re-read the staged session_event rows; local has no such rows). */
|
|
@@ -173,15 +174,14 @@ export declare class LocalSessionStore implements SessionStore {
|
|
|
173
174
|
/** K-5c (core 1.155 SessionStore seam): core's Runner calls this at runTask START with the run's taskId, so the
|
|
174
175
|
* latest run surfaces as `lastRunId` on listSessions even while it is still running/suspended (the local backend
|
|
175
176
|
* has no runs ledger to derive it from, unlike the TiDB/PG twins). In-memory, last-write-wins per session.
|
|
176
|
-
* [ref](
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* (
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
noteTaskRun(sessionId: string, taskId: string): void;
|
|
177
|
+
* [ref] → [ref]([ref] 兑现;上一版「有意缓议」注即本批债锚,兑现随批撤):第三参 engine runId
|
|
178
|
+
* 自本批起**消费** —— map 值 {taskId, runId?} + 行投影 `lastTaskRunId` + **两座同动清座**(omit ⇒
|
|
179
|
+
* 清 runId 座,对齐 core TtlSessionStore.noteTaskRun 的 omit⇒clear 契约,core session.d.ts:204-212
|
|
180
|
+
* 亲读:旧 run 的 runId 挂在新 task 旁 = 把上一条 run 的身份错标给新 run)。存储与行投影同批落
|
|
181
|
+
* (codex 5.65 R1-[medium]「存储先行、投影另批」之驳仍成立:本店 runId 唯一读点即行投影)。
|
|
182
|
+
* wire 半场:HTTP 面零投影透传(sessions-list.ts,additive 键随行上 wire);sema-sdk spec
|
|
183
|
+
* `SessionSummary` 闭集候 SDK 开键([ref]/.82 班车)——SDK 落键即端到端闭合。 */
|
|
184
|
+
noteTaskRun(sessionId: string, taskId: string, runId?: string): void;
|
|
185
185
|
/** Synthesize a session's objective preview = the most-recent USER text message (the objective the run last saw).
|
|
186
186
|
* Reads the session's entries through the repo; any read error degrades to null (never fails the list). */
|
|
187
187
|
private previewOf;
|
|
@@ -275,6 +275,7 @@ export class LocalSessionStore {
|
|
|
275
275
|
continue;
|
|
276
276
|
const firstActivityAt = meta.createdAt || new Date(0).toISOString();
|
|
277
277
|
const lastActivityAt = this.lastActivity.get(meta.id) ?? firstActivityAt;
|
|
278
|
+
const lastRun = this.lastTaskRun.get(meta.id);
|
|
278
279
|
all.push({
|
|
279
280
|
sessionId: meta.id,
|
|
280
281
|
owner,
|
|
@@ -283,7 +284,8 @@ export class LocalSessionStore {
|
|
|
283
284
|
runCount: 0,
|
|
284
285
|
objectivePreview: await this.previewOf(meta),
|
|
285
286
|
lastStatus: "",
|
|
286
|
-
lastRunId:
|
|
287
|
+
lastRunId: lastRun?.taskId ?? null,
|
|
288
|
+
...(lastRun?.runId !== undefined ? { lastTaskRunId: lastRun.runId } : {}),
|
|
287
289
|
title: this.titleState.titles.get(meta.id) ?? null,
|
|
288
290
|
});
|
|
289
291
|
}
|
|
@@ -298,8 +300,8 @@ export class LocalSessionStore {
|
|
|
298
300
|
: filtered;
|
|
299
301
|
return after.slice(0, opts.limit);
|
|
300
302
|
}
|
|
301
|
-
noteTaskRun(sessionId, taskId) {
|
|
302
|
-
this.lastTaskRun.set(sessionId, taskId);
|
|
303
|
+
noteTaskRun(sessionId, taskId, runId) {
|
|
304
|
+
this.lastTaskRun.set(sessionId, { taskId, ...(runId !== undefined ? { runId } : {}) });
|
|
303
305
|
}
|
|
304
306
|
async previewOf(meta) {
|
|
305
307
|
try {
|
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory async run registry + replayable event log — the LOCAL (DB-less) twin of {@link TiDBRunStore}/
|
|
3
|
-
* {@link PgRunStore} (clay 2026-06-25: the seamless local↔cloud switch — a locally-run HTTP service must serve the
|
|
4
|
-
* SAME contract a cloud worker does, incl. `/v1/runs` async runs + the trace event log). Behaviourally identical to
|
|
5
|
-
* the SQL twins (the shared `storeBehaviorSuite` keeps them in lock-step), but process-local + ephemeral: a restart
|
|
6
|
-
* loses run history (file-backed durability is the immediate follow-on; the conversation HISTORY + checkpoints DO
|
|
7
|
-
* persist via core's FileStorageBackend). Single-process, so the cross-replica/durable reapers degrade — see below.
|
|
8
|
-
*
|
|
9
|
-
* Mirrors the SQL semantics exactly: the `task_active` single-active-run claim (a Map keyed by sessionId), the
|
|
10
|
-
* NON-terminal suspend/needs_review parks that KEEP the claim, the markResuming CAS that resets cancel/preempt, the
|
|
11
|
-
* owner null-safe guard on the flag/heartbeat paths (`<=>` → JS `(a??null)===(b??null)`), and the keyset list order.
|
|
12
|
-
*/
|
|
13
1
|
import { type UsageRow } from "../usage-analytics.js";
|
|
14
|
-
import type {
|
|
15
|
-
import type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
|
|
2
|
+
import type { TaskStatus } from "@sema-agent/core";
|
|
3
|
+
import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
|
|
16
4
|
import type { LedgerEventType } from "../trace/ledger-events.js";
|
|
17
5
|
/**
|
|
18
6
|
* [ref] P0 — the local lane's stand-in for the SQL twins' checkpoint-table JOIN. The file/memory run stores
|
|
@@ -66,7 +54,7 @@ export declare class MemoryRunStore {
|
|
|
66
54
|
getEvents(taskId: string, afterSeq: number): Promise<RunEvent[]>;
|
|
67
55
|
/** [ref] FIRST terminal writer wins(SQL 孪生同款正向 CAS on running/suspended/needs_review,
|
|
68
56
|
* [1.207 codex M1] 负向形漏 blocked/timeout);claim 释放保持无条件(幂等)。 */
|
|
69
|
-
setTerminal(taskId: string, status: TaskStatus, result:
|
|
57
|
+
setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise<void>;
|
|
70
58
|
/** Durable F4: park NON-terminal `suspended`, KEEP the task_active claim. CAS on running/suspended (reaper-revert guard). */
|
|
71
59
|
setSuspended(taskId: string): Promise<void>;
|
|
72
60
|
/** [ref] D-B: park NON-terminal `needs_review`, KEEP the claim. CAS on running/needs_review. */
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { redactErrorMessage, redactLedgerEventData, redactTerminalResult } from "../observability/run-terminal-log.js";
|
|
1
2
|
import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
|
|
3
|
+
import { notifyRunTerminal } from "../observability/run-terminal-log.js";
|
|
2
4
|
const ownerEq = (a, b) => (a ?? null) === (b ?? null);
|
|
3
5
|
export class MemoryRunStore {
|
|
4
6
|
runs = new Map();
|
|
@@ -78,6 +80,7 @@ export class MemoryRunStore {
|
|
|
78
80
|
r.updatedAt = new Date();
|
|
79
81
|
}
|
|
80
82
|
async appendEvent(taskId, seq, type, data) {
|
|
83
|
+
data = redactLedgerEventData(type, data);
|
|
81
84
|
const list = this.events.get(taskId) ?? [];
|
|
82
85
|
if (list.some((e) => e.seq === seq))
|
|
83
86
|
return;
|
|
@@ -100,6 +103,8 @@ export class MemoryRunStore {
|
|
|
100
103
|
.map((e) => ({ ...e, data: e.data == null ? null : structuredClone(e.data) }));
|
|
101
104
|
}
|
|
102
105
|
async setTerminal(taskId, status, result, error) {
|
|
106
|
+
result = redactTerminalResult(result);
|
|
107
|
+
error = error === null ? null : (redactErrorMessage(error) ?? null);
|
|
103
108
|
const r = this.runs.get(taskId);
|
|
104
109
|
if (!r) {
|
|
105
110
|
for (const [sessionId, tid] of this.active)
|
|
@@ -109,15 +114,29 @@ export class MemoryRunStore {
|
|
|
109
114
|
}
|
|
110
115
|
return;
|
|
111
116
|
}
|
|
117
|
+
let pending;
|
|
112
118
|
if (r.status === "running" || r.status === "suspended" || r.status === "needs_review") {
|
|
113
119
|
r.status = status;
|
|
114
120
|
r.result = result;
|
|
115
121
|
r.error = error;
|
|
116
122
|
r.errorCode = result?.errorCode ?? null;
|
|
117
123
|
r.updatedAt = new Date();
|
|
124
|
+
pending = {
|
|
125
|
+
runId: taskId,
|
|
126
|
+
sessionId: r.sessionId,
|
|
127
|
+
owner: r.owner,
|
|
128
|
+
status,
|
|
129
|
+
resultTaskId: result?.taskId,
|
|
130
|
+
model: result?.model,
|
|
131
|
+
errorCode: r.errorCode ?? undefined,
|
|
132
|
+
errorMessage: result?.errorMessage ?? error ?? undefined,
|
|
133
|
+
elapsedMs: Math.max(0, Date.now() - r.createdAt.getTime()),
|
|
134
|
+
};
|
|
118
135
|
}
|
|
119
136
|
if (this.active.get(r.sessionId) === taskId)
|
|
120
137
|
this.active.delete(r.sessionId);
|
|
138
|
+
if (pending)
|
|
139
|
+
notifyRunTerminal(pending);
|
|
121
140
|
}
|
|
122
141
|
async setSuspended(taskId) {
|
|
123
142
|
const r = this.runs.get(taskId);
|
|
@@ -186,6 +205,7 @@ export class MemoryRunStore {
|
|
|
186
205
|
continue;
|
|
187
206
|
const sorted = [...arr].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0));
|
|
188
207
|
const latest = sorted[0];
|
|
208
|
+
const latestRunId = latest.result?.runId;
|
|
189
209
|
const times = arr.map((r) => r.createdAt.getTime());
|
|
190
210
|
const last = Math.max(...times);
|
|
191
211
|
const first = Math.min(...times);
|
|
@@ -198,6 +218,7 @@ export class MemoryRunStore {
|
|
|
198
218
|
objectivePreview: latest.objectivePreview,
|
|
199
219
|
lastStatus: latest.status,
|
|
200
220
|
lastRunId: latest.taskId,
|
|
221
|
+
...(typeof latestRunId === "string" && latestRunId !== "" ? { lastTaskRunId: latestRunId } : {}),
|
|
201
222
|
title: null,
|
|
202
223
|
});
|
|
203
224
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope, type StaleRuleApprovalRecord } from "@sema-agent/core";
|
|
2
|
-
import { type RuleImportTicket, type
|
|
2
|
+
import { type RuleImportTicket, type RuleTicketPurpose, type RuleTicketReclaimResult, type RuleTicketRedeemResult, type RuleTicketSnapshot } from "./permission-rule-store-sql.js";
|
|
3
3
|
import type { PermissionRuleStoreBundle } from "../rules-consent.js";
|
|
4
4
|
/**
|
|
5
5
|
* `RuleApprovalRecordStore` 的 File 形。
|
|
@@ -29,17 +29,34 @@ export declare class FileRuleApprovalRecordStore implements RuleApprovalRecordSt
|
|
|
29
29
|
/**
|
|
30
30
|
* CC 导入票的 File 形。四条信任边界(principal 绑定 / TTL / 一次性原子消费 / 载荷绑定)与 SQL 形
|
|
31
31
|
* **同语义**;唯一不同的是「原子」由谁保证 —— 那边是引擎行锁,这边是单进程 + 单写者(顶注)。
|
|
32
|
+
*
|
|
33
|
+
* 🔴 **每只店对自己那口钟负责**([ref]):SQL 形的 TTL 轴锚在数据库的钟上(多副本共享的唯一一口);
|
|
34
|
+
* local 形没有那样一口外部钟,它的钟就是本进程的 —— 而进程墙钟**不单调**,一次 NTP 回拨就能让一张
|
|
35
|
+
* 真实已经走满 TTL 的票复活(codex 对抗复审 r1 [high],验真后修)。所以本形自己带一枚**单调锚**:
|
|
36
|
+
* 本进程铸的票,判定钟 = `铸票墙钟 + 自铸票起单调钟走过的量`;重启后重放回来的行没有锚,如实退回墙钟。
|
|
32
37
|
*/
|
|
33
38
|
export declare class FileRuleImportTicketStore {
|
|
34
39
|
private readonly rows;
|
|
35
40
|
private readonly log;
|
|
36
41
|
private readonly gate;
|
|
37
|
-
|
|
42
|
+
/** 单调钟(判定用)。与 {@link now}(墙钟,记账用)是两只不同的钟,别混。 */
|
|
43
|
+
private readonly monotonic;
|
|
44
|
+
constructor(dir: string, now?: () => number, onError?: (message: string) => void, monotonic?: () => number);
|
|
38
45
|
private readonly now;
|
|
39
46
|
/** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
|
|
40
47
|
get corrupt(): boolean;
|
|
41
48
|
/** 归还本面 eager 持有的日志描述符(理由与姊妹面 {@link FileRuleApprovalRecordStore.close} 逐字同源)。 */
|
|
42
49
|
close(): void;
|
|
50
|
+
/**
|
|
51
|
+
* 这一刻的**判定钟**([ref] r1-F1)。
|
|
52
|
+
*
|
|
53
|
+
* 本进程铸的票 ⇒ `铸票墙钟 + 自铸票起单调钟走过的量`:墙钟被前拨不会误杀(真实只过了几秒的票还活着),
|
|
54
|
+
* 被回拨也不会续命(单调钟照走)。重启后重放回来的行没有锚 ⇒ 退回墙钟(单调钟的零点跨进程无意义)。
|
|
55
|
+
*
|
|
56
|
+
* **给不了票额外寿命**:投影严格按单调钟前进,真实走满 TTL 必然越过 `expiresAtMs`(负控钉在
|
|
57
|
+
* `permission-rule-store-file.test.ts` 的 [ref] r1-F1 族)。
|
|
58
|
+
*/
|
|
59
|
+
private decisionNowMs;
|
|
43
60
|
mint(input: {
|
|
44
61
|
ticketId: string;
|
|
45
62
|
principal: string;
|
|
@@ -53,11 +70,11 @@ export declare class FileRuleImportTicketStore {
|
|
|
53
70
|
}): Promise<RuleImportTicket>;
|
|
54
71
|
/** 一次性**认领**。五条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → wrong-purpose →
|
|
55
72
|
* consumed → expired),于是两形的服务端日志归因可比;wire 面把五类折成同一个 404(零存在性 oracle)。 */
|
|
56
|
-
consume(ticketId: string, principal: string, purpose: RuleTicketPurpose
|
|
73
|
+
consume(ticketId: string, principal: string, purpose: RuleTicketPurpose): Promise<RuleTicketRedeemResult>;
|
|
57
74
|
/** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
|
|
58
75
|
* (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。
|
|
59
76
|
* `claimId` = A7 的认领围栏(R1-F1):被顶替的旧持有者动不了现持有者的认领。 */
|
|
60
|
-
release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number
|
|
77
|
+
release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number): Promise<boolean>;
|
|
61
78
|
/** [ref] A7 CLAIMED → **SETTLED**。判据与 SQL 孪生逐字同源(持有**这一代**认领 ∧ 未落定;过期
|
|
62
79
|
* **刻意不设门** —— 理由逐字见 SQL 侧 `settle` 的头注:拒绝记录一件已经发生的事只会把真相丢掉)。 */
|
|
63
80
|
settle(ticketId: string, principal: string, claimId: string, outcome: unknown): Promise<boolean>;
|
|
@@ -89,5 +106,6 @@ export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
|
|
|
89
106
|
export declare function createFilePermissionRuleStores(root: string, opts?: {
|
|
90
107
|
onError?: (message: string) => void;
|
|
91
108
|
now?: () => number;
|
|
109
|
+
monotonic?: () => number;
|
|
92
110
|
}): FilePermissionRuleStores;
|
|
93
111
|
//# sourceMappingURL=permission-rule-store-file.d.ts.map
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
4
5
|
import { FilePermissionRuleStoreProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
|
|
8
|
+
import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
7
9
|
const RuleScopeSchema = z.union([
|
|
8
10
|
z.object({ kind: z.literal("global") }).strict(),
|
|
9
11
|
z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
|
|
@@ -18,6 +20,7 @@ const RuleOffer2Schema = z.union([
|
|
|
18
20
|
candidates: z.array(z.number().int().nonnegative().safe()),
|
|
19
21
|
segments: z.array(z.string()).optional(),
|
|
20
22
|
uncoveredSegments: z.number().int().nonnegative().safe().optional(),
|
|
23
|
+
uncoveredDetail: z.array(z.object({ segment: z.string(), reason: z.enum(UNCOVERED_SEGMENT_REASONS) }).strict()).optional(),
|
|
21
24
|
})
|
|
22
25
|
.strict(),
|
|
23
26
|
]);
|
|
@@ -26,7 +29,7 @@ const ApprovalRecordSchema = z
|
|
|
26
29
|
id: z.string().min(1),
|
|
27
30
|
principal: z.string().optional(),
|
|
28
31
|
owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
|
|
29
|
-
schema: z.literal(
|
|
32
|
+
schema: z.literal(RULE_APPROVAL_RECORD_SCHEMA),
|
|
30
33
|
kind: z.enum(["card", "import", "starter"]),
|
|
31
34
|
state: z.enum(["pending", "approved", "redeemed"]),
|
|
32
35
|
candidates: z.array(RuleCandidateSchema),
|
|
@@ -35,7 +38,10 @@ const ApprovalRecordSchema = z
|
|
|
35
38
|
toolCallId: z.string().optional(),
|
|
36
39
|
boundInputHash: z.string().optional(),
|
|
37
40
|
command: z.string().optional(),
|
|
38
|
-
edited: z
|
|
41
|
+
edited: z
|
|
42
|
+
.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string(), warnings: z.array(z.enum(EDITED_RULE_BREADTH_WARNING_CODES)).optional() })
|
|
43
|
+
.strict()
|
|
44
|
+
.optional(),
|
|
39
45
|
rev: z.number().int().nonnegative().safe(),
|
|
40
46
|
selectedOffer: z.number().int().nonnegative().safe().optional(),
|
|
41
47
|
redeemedDots: z.record(z.string(), RuleDotSchema).optional(),
|
|
@@ -137,7 +143,7 @@ export class FileRuleApprovalRecordStore {
|
|
|
137
143
|
const parsed = ApprovalLineSchema.safeParse(raw);
|
|
138
144
|
if (!parsed.success) {
|
|
139
145
|
const stale = StaleApprovalLineSchema.safeParse(raw);
|
|
140
|
-
if (stale.success && stale.data.record.schema !==
|
|
146
|
+
if (stale.success && stale.data.record.schema !== RULE_APPROVAL_RECORD_SCHEMA) {
|
|
141
147
|
this.rows.set(stale.data.record.id, toStaleEnvelope(stale.data.record));
|
|
142
148
|
continue;
|
|
143
149
|
}
|
|
@@ -195,8 +201,10 @@ export class FileRuleImportTicketStore {
|
|
|
195
201
|
rows = new Map();
|
|
196
202
|
log;
|
|
197
203
|
gate;
|
|
198
|
-
|
|
204
|
+
monotonic;
|
|
205
|
+
constructor(dir, now, onError, monotonic) {
|
|
199
206
|
this.now = now ?? Date.now;
|
|
207
|
+
this.monotonic = monotonic ?? (() => performance.now());
|
|
200
208
|
ensureDir(dir);
|
|
201
209
|
const path = join(dir, "import-tickets.jsonl");
|
|
202
210
|
this.gate = new CorruptionGate("permission-rule import-ticket log", path, onError);
|
|
@@ -247,15 +255,21 @@ export class FileRuleImportTicketStore {
|
|
|
247
255
|
close() {
|
|
248
256
|
this.log.close();
|
|
249
257
|
}
|
|
258
|
+
decisionNowMs(row) {
|
|
259
|
+
const a = row.mintAnchor;
|
|
260
|
+
return a === undefined ? this.now() : a.wallMs + (this.monotonic() - a.monoMs);
|
|
261
|
+
}
|
|
250
262
|
async mint(input) {
|
|
251
263
|
this.gate.assertUsable();
|
|
252
|
-
const
|
|
264
|
+
const mintedAtWall = this.now();
|
|
265
|
+
const mintedAtMono = this.monotonic();
|
|
266
|
+
const expiresAtMs = mintedAtWall + input.ttlMs;
|
|
253
267
|
const payloadHash = buildRulePayloadHash(input.candidates);
|
|
254
268
|
this.log.append({ op: "mint", ticketId: input.ticketId, ownerKey: input.principal, approvalId: input.approvalId, payloadHash, purpose: input.purpose, expiresAtMs }, true);
|
|
255
|
-
this.rows.set(input.ticketId, { ownerKey: input.principal, approvalId: input.approvalId, payloadHash, purpose: input.purpose, expiresAtMs });
|
|
269
|
+
this.rows.set(input.ticketId, { ownerKey: input.principal, approvalId: input.approvalId, payloadHash, purpose: input.purpose, expiresAtMs, mintAnchor: { wallMs: mintedAtWall, monoMs: mintedAtMono } });
|
|
256
270
|
return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
|
|
257
271
|
}
|
|
258
|
-
async consume(ticketId, principal, purpose
|
|
272
|
+
async consume(ticketId, principal, purpose) {
|
|
259
273
|
this.gate.assertUsable();
|
|
260
274
|
const row = this.rows.get(ticketId);
|
|
261
275
|
if (row === undefined)
|
|
@@ -267,7 +281,7 @@ export class FileRuleImportTicketStore {
|
|
|
267
281
|
if (row.consumedAtMs !== undefined)
|
|
268
282
|
return { ok: false, reason: "consumed" };
|
|
269
283
|
const stampMs = this.now();
|
|
270
|
-
if (row.expiresAtMs <= (
|
|
284
|
+
if (row.expiresAtMs <= this.decisionNowMs(row))
|
|
271
285
|
return { ok: false, reason: "expired" };
|
|
272
286
|
const claimId = randomUUID();
|
|
273
287
|
this.log.append({ op: "consume", ticketId, atMs: stampMs, claimId }, true);
|
|
@@ -275,12 +289,12 @@ export class FileRuleImportTicketStore {
|
|
|
275
289
|
row.claimId = claimId;
|
|
276
290
|
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash, claimId };
|
|
277
291
|
}
|
|
278
|
-
async release(ticketId, principal, claimId, mustRemainValidMs = 0
|
|
292
|
+
async release(ticketId, principal, claimId, mustRemainValidMs = 0) {
|
|
279
293
|
this.gate.assertUsable();
|
|
280
294
|
const row = this.rows.get(ticketId);
|
|
281
295
|
if (row === undefined || row.ownerKey !== principal || row.consumedAtMs === undefined || row.claimId !== claimId || row.outcome !== undefined)
|
|
282
296
|
return false;
|
|
283
|
-
if (row.expiresAtMs <=
|
|
297
|
+
if (row.expiresAtMs <= this.decisionNowMs(row) + mustRemainValidMs)
|
|
284
298
|
return false;
|
|
285
299
|
this.log.append({ op: "release", ticketId }, true);
|
|
286
300
|
delete row.consumedAtMs;
|
|
@@ -329,7 +343,7 @@ export function createFilePermissionRuleStores(root, opts) {
|
|
|
329
343
|
ensureDir(dir);
|
|
330
344
|
const provider = opts?.onError ? new FilePermissionRuleStoreProvider(dir, opts.onError) : new FilePermissionRuleStoreProvider(dir);
|
|
331
345
|
const approvals = new FileRuleApprovalRecordStore(dir, opts?.onError);
|
|
332
|
-
const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError);
|
|
346
|
+
const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError, opts?.monotonic);
|
|
333
347
|
return {
|
|
334
348
|
provider,
|
|
335
349
|
approvals,
|