@sema-agent/server 7.43.0 → 7.44.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/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
|
@@ -4,27 +4,20 @@ import { pgSanitizeText } from "./pg-safe-json.js";
|
|
|
4
4
|
import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
|
|
5
5
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
6
6
|
import { isDupKeyError } from "./sql-errors.js";
|
|
7
|
-
/** Dual-dialect async run registry + event log. See the file header for the dialect-delta ledger. */
|
|
8
7
|
export class SqlRunStore {
|
|
9
8
|
db;
|
|
10
9
|
constructor(db) {
|
|
11
10
|
this.db = db;
|
|
12
11
|
}
|
|
13
|
-
/** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
|
|
14
12
|
q(tidb, pg) {
|
|
15
13
|
return this.db.dialect === "tidb" ? tidb : pg;
|
|
16
14
|
}
|
|
17
|
-
/** Dup-key classification — single owner in `sql-errors.ts` (A-032 P1-①; the old local form read
|
|
18
|
-
* only mysql2's string `code`, so an error carrying just `errno` fell through the claim-race arm). */
|
|
19
15
|
isDupKey(err) {
|
|
20
16
|
return isDupKeyError(this.db.dialect, err);
|
|
21
17
|
}
|
|
22
|
-
/** JSON column binding: TiDB stores the string verbatim; PG goes through the protocol-safe envelope
|
|
23
|
-
* (a raw NUL byte in an event payload would otherwise throw 22P05 mid-write). */
|
|
24
18
|
json(value) {
|
|
25
19
|
return dialectJsonEncoder(this.db.dialect)(value);
|
|
26
20
|
}
|
|
27
|
-
/** Run statements atomically; rolls back on failure. */
|
|
28
21
|
async tx(fn) {
|
|
29
22
|
const conn = await this.db.connect();
|
|
30
23
|
try {
|
|
@@ -37,7 +30,6 @@ export class SqlRunStore {
|
|
|
37
30
|
await conn.rollback();
|
|
38
31
|
}
|
|
39
32
|
catch {
|
|
40
|
-
/* ignore */
|
|
41
33
|
}
|
|
42
34
|
throw err;
|
|
43
35
|
}
|
|
@@ -45,14 +37,6 @@ export class SqlRunStore {
|
|
|
45
37
|
conn.release();
|
|
46
38
|
}
|
|
47
39
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Create a run, but only if the session has no other active run (double-text guard).
|
|
50
|
-
*
|
|
51
|
-
* Mutual exclusion is a unique-key claim on `task_active(session_id)` — NOT a SELECT…FOR UPDATE,
|
|
52
|
-
* which on TiDB (no gap locks) would fail to serialize two concurrent createRuns when the session
|
|
53
|
-
* has no prior row. A plain INSERT either claims the session or throws a duplicate-key error (TiDB
|
|
54
|
-
* does NOT report a reliable affectedRows for `ON DUPLICATE KEY UPDATE …=…`, so we catch dup-key).
|
|
55
|
-
*/
|
|
56
40
|
async createRun(taskId, sessionId, owner, instanceId, meta = {}) {
|
|
57
41
|
const conn = await this.db.connect();
|
|
58
42
|
try {
|
|
@@ -78,8 +62,6 @@ export class SqlRunStore {
|
|
|
78
62
|
instanceId,
|
|
79
63
|
meta.jobId ?? null,
|
|
80
64
|
meta.source ?? null,
|
|
81
|
-
// PG-only (R12 同族): objective 预览=模型可控标量,走 pgSanitizeText(NUL/protocol-byte 防护)。
|
|
82
|
-
// TiDB 存 verbatim(无该字节类)。
|
|
83
65
|
this.db.dialect === "tidb"
|
|
84
66
|
? meta.objectivePreview ?? null
|
|
85
67
|
: meta.objectivePreview == null
|
|
@@ -96,7 +78,6 @@ export class SqlRunStore {
|
|
|
96
78
|
await conn.rollback();
|
|
97
79
|
}
|
|
98
80
|
catch {
|
|
99
|
-
/* ignore */
|
|
100
81
|
}
|
|
101
82
|
throw err;
|
|
102
83
|
}
|
|
@@ -104,79 +85,39 @@ export class SqlRunStore {
|
|
|
104
85
|
conn.release();
|
|
105
86
|
}
|
|
106
87
|
}
|
|
107
|
-
/** Request cancel of a RUNNING run (durable, cross-replica). Sets the flag the owning instance's heartbeat
|
|
108
|
-
* tick polls; a same-replica caller additionally aborts the in-flight controller for instant effect. Only
|
|
109
|
-
* flags a `running` row (terminal/suspended → 0 affected = no-op). Returns whether a running row was flagged.
|
|
110
|
-
*
|
|
111
|
-
* `owner` is a single-DB-fleet defense-in-depth guard: the WHERE pins the row to the authenticated owner
|
|
112
|
-
* (null-safe compare so a legacy null-owner run matches owner=null), so even if the HTTP owner-gate has a bug
|
|
113
|
-
* a caller can never flip another tenant's run. Pass the run's owner (from the looked-up RunRecord). */
|
|
114
88
|
async requestCancel(taskId, owner) {
|
|
115
89
|
const { affected } = await this.db.query(this.q("UPDATE task_run SET cancel_requested = 1 WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET cancel_requested = 1 WHERE task_id = $1 AND owner IS NOT DISTINCT FROM $2 AND status = 'running'"), [taskId, owner]);
|
|
116
90
|
return affected > 0;
|
|
117
91
|
}
|
|
118
|
-
/** Whether a cancel has been requested for this run (polled by the owning instance's heartbeat tick).
|
|
119
|
-
* `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */
|
|
120
92
|
async isCancelRequested(taskId, owner) {
|
|
121
93
|
const { rows } = await this.db.query(this.q("SELECT cancel_requested FROM task_run WHERE task_id = ? AND owner <=> ?", "SELECT cancel_requested FROM task_run WHERE task_id = $1 AND owner IS NOT DISTINCT FROM $2"), [taskId, owner]);
|
|
122
94
|
return Boolean(rows[0]?.cancel_requested);
|
|
123
95
|
}
|
|
124
|
-
/** design/80 seam #2: request PREEMPT (graceful durable yield, ≠ cancel's kill) of a RUNNING run — durable +
|
|
125
|
-
* cross-replica. Sets the flag the owning instance's heartbeat tick polls (it then aborts its preemptSignal →
|
|
126
|
-
* the task suspends at the next clean turn boundary, gate resource_limit/reason preempt); a same-replica caller
|
|
127
|
-
* additionally aborts the in-flight preempt controller for instant effect. Only flags a `running` row
|
|
128
|
-
* (terminal/suspended → 0 affected = no-op). Returns whether a running row was flagged.
|
|
129
|
-
* `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */
|
|
130
96
|
async requestPreempt(taskId, owner) {
|
|
131
97
|
const { affected } = await this.db.query(this.q("UPDATE task_run SET preempt_requested = 1 WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET preempt_requested = 1 WHERE task_id = $1 AND owner IS NOT DISTINCT FROM $2 AND status = 'running'"), [taskId, owner]);
|
|
132
98
|
return affected > 0;
|
|
133
99
|
}
|
|
134
|
-
/** Whether a preempt has been requested for this run (polled by the owning instance's heartbeat tick).
|
|
135
|
-
* `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */
|
|
136
100
|
async isPreemptRequested(taskId, owner) {
|
|
137
101
|
const { rows } = await this.db.query(this.q("SELECT preempt_requested FROM task_run WHERE task_id = ? AND owner <=> ?", "SELECT preempt_requested FROM task_run WHERE task_id = $1 AND owner IS NOT DISTINCT FROM $2"), [taskId, owner]);
|
|
138
102
|
return Boolean(rows[0]?.preempt_requested);
|
|
139
103
|
}
|
|
140
|
-
/** Liveness heartbeat: refresh updated_at for a still-running run, independent of events. The
|
|
141
|
-
* running instance calls this on a timer so a long-but-alive turn (e.g. a subagent that emits no
|
|
142
|
-
* parent events for minutes) is not mistaken for a dead instance.
|
|
143
|
-
* `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}); the heartbeat is
|
|
144
|
-
* always issued by the owning instance with the run's own owner, so this only narrows, never blocks. */
|
|
145
104
|
async heartbeat(taskId, owner) {
|
|
146
105
|
await this.db.query(this.q("UPDATE task_run SET updated_at = ? WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET updated_at = $1 WHERE task_id = $2 AND owner IS NOT DISTINCT FROM $3 AND status = 'running'"), [new Date(), taskId, owner]);
|
|
147
106
|
}
|
|
148
|
-
/** Append one event. A single INSERT — `updated_at` (liveness) is owned by `heartbeat`, so there
|
|
149
|
-
* is no second statement to keep atomic here. */
|
|
150
107
|
async appendEvent(taskId, seq, type, data) {
|
|
151
108
|
await this.db.query(this.q("INSERT INTO task_event (task_id, seq, type, data, ts) VALUES (?,?,?,?,?)", "INSERT INTO task_event (task_id, seq, type, data, ts) VALUES ($1,$2,$3,$4::jsonb,$5)"), [taskId, seq, type, data == null ? null : this.json(data), new Date()]);
|
|
152
109
|
}
|
|
153
|
-
/** Highest event seq for a task (0 if none). Lets a resumed leg CONTINUE the durable event log past the
|
|
154
|
-
* suspend (core 1.70 `resumeStream`) — the suspend wrote events 1..K, the resume appends from K+1 without
|
|
155
|
-
* colliding on the (task_id, seq) PK. */
|
|
156
110
|
async maxSeq(taskId) {
|
|
157
111
|
const { rows } = await this.db.query(this.q("SELECT MAX(seq) AS m FROM task_event WHERE task_id = ?", "SELECT MAX(seq) AS m FROM task_event WHERE task_id = $1"), [taskId]);
|
|
158
112
|
const m = rows[0]?.m;
|
|
159
113
|
return m == null ? 0 : Number(m);
|
|
160
114
|
}
|
|
161
|
-
/** Mark terminal AND release the single-active-run claim atomically — if the DELETE were a separate
|
|
162
|
-
* statement that failed, the session would stay permanently locked. [888] FIRST terminal writer wins:
|
|
163
|
-
* the UPDATE is CAS'd POSITIVELY on the live statuses (running/suspended/needs_review) — every call site
|
|
164
|
-
* is a settle-once flow whose backstops may double-fire (cancel × leg-throw × reaper), and an
|
|
165
|
-
* unconditional write let the LAST racer overwrite errorCode (a `cancelled` row flipping to a generic
|
|
166
|
-
* abort text, or vice versa). Positive form ([1.207 codex M1]): the negative NOT IN missed the
|
|
167
|
-
* blocked/timeout terminals — a first write of those could still be overwritten. The claim DELETE stays
|
|
168
|
-
* unconditional (idempotent; the first writer already released it). */
|
|
169
115
|
async setTerminal(taskId, status, result, error) {
|
|
170
|
-
// Denormalize the structured code into its own column so operators can `WHERE error_code LIKE
|
|
171
|
-
// 'budget.%'` without parsing every result JSON. Source of truth stays the result blob.
|
|
172
116
|
const errorCode = result?.errorCode ?? null;
|
|
173
117
|
await this.tx(async (conn) => {
|
|
174
118
|
await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
|
|
175
119
|
status,
|
|
176
120
|
result == null ? null : this.json(result),
|
|
177
|
-
// codex R12 (PG only): error/errorCode = 裸 TEXT 参数 — 真 NUL 直炸 22P05,UPDATE 抛 → 事务回滚 →
|
|
178
|
-
// task_active 保持 → run 卡非终态到 reaper(错误文案带脏字节的形:tool 报错内嵌 raw 输出)。观测标量
|
|
179
|
-
// 同族有损 sanitize(与 result jsonb 同臂同纪律)。TiDB 存 verbatim。
|
|
180
121
|
this.db.dialect === "tidb" ? error : error == null ? null : pgSanitizeText(error),
|
|
181
122
|
this.db.dialect === "tidb" ? errorCode : errorCode == null ? null : pgSanitizeText(errorCode),
|
|
182
123
|
new Date(),
|
|
@@ -185,69 +126,20 @@ export class SqlRunStore {
|
|
|
185
126
|
await conn.query(this.q("DELETE FROM task_active WHERE task_id = ?", "DELETE FROM task_active WHERE task_id = $1"), [taskId]);
|
|
186
127
|
});
|
|
187
128
|
}
|
|
188
|
-
/**
|
|
189
|
-
* Durable F4 (design/45): a task suspended on a policy `ask`. Sets the run NON-terminal `suspended` and
|
|
190
|
-
* KEEPS the `task_active` claim — the session stays locked (no new run) until an operator resumes it to a
|
|
191
|
-
* terminal state or the reaper expires it. The token-bearing result is NOT stored (the submitter polling
|
|
192
|
-
* this row must never receive the capability token); only the status flips.
|
|
193
|
-
*/
|
|
194
129
|
async setSuspended(taskId) {
|
|
195
|
-
// CAS on `running` (idempotent on an already-suspended row): without it, a heartbeat-stall race where
|
|
196
|
-
// `reapStale` already flipped the row to `failed` and DELETED its `task_active` claim would be reverted
|
|
197
|
-
// here to `suspended` WITHOUT re-claiming `task_active` — leaving a suspended run with no session lock, so
|
|
198
|
-
// a new run could start on the same session while the checkpoint is still pending. The CAS makes the
|
|
199
|
-
// reaped→suspended revert a no-op, so the reaper's decision stands and the invariant holds.
|
|
200
130
|
await this.db.query(this.q("UPDATE task_run SET status = 'suspended', updated_at = ? WHERE task_id = ? AND status IN ('running','suspended')", "UPDATE task_run SET status = 'suspended', updated_at = $1 WHERE task_id = $2 AND status IN ('running','suspended')"), [new Date(), taskId]);
|
|
201
131
|
}
|
|
202
|
-
/**
|
|
203
|
-
* design/80 D-B: PARK a run that paused on a REVIEW gate — a `needs_review` result (a dry-run diff review OR a
|
|
204
|
-
* pre-action `plan_review`; core assemble-result slot 8.6). Like {@link setSuspended} this is a NON-terminal park
|
|
205
|
-
* that KEEPS the `task_active` session claim (it does NOT delete it — only {@link setTerminal} does), so the
|
|
206
|
-
* resume path (`getActiveTaskId` → `markResuming`) can find + claim the row. core treats a review pause "like a
|
|
207
|
-
* suspend, an INTENTIONAL durable pause" (design/76 §2.5) — the service MUST park it the same way, else the run is
|
|
208
|
-
* terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
|
|
209
|
-
* taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
|
|
210
|
-
* (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
|
|
211
|
-
* `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
|
|
212
|
-
* replicas racing one session ⇒ exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
|
|
213
|
-
* claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
|
|
214
|
-
* needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when
|
|
215
|
-
* the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111
|
|
216
|
-
* mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
|
|
217
|
-
* or a multi-table reaper DELETE.
|
|
218
|
-
*/
|
|
219
132
|
async setNeedsReview(taskId) {
|
|
220
133
|
await this.db.query(this.q("UPDATE task_run SET status = 'needs_review', updated_at = ? WHERE task_id = ? AND status IN ('running','needs_review')", "UPDATE task_run SET status = 'needs_review', updated_at = $1 WHERE task_id = $2 AND status IN ('running','needs_review')"), [new Date(), taskId]);
|
|
221
134
|
}
|
|
222
|
-
/**
|
|
223
|
-
* Durable F4 resume: a suspended run is being actively resumed. Flip it back to `running` (CAS — only if
|
|
224
|
-
* still suspended) and refresh liveness, so the in-flight resume is protected by the running-run heartbeat
|
|
225
|
-
* and is NOT killed by `reapSuspended` (which targets `status='suspended'` past the approval TTL). Without
|
|
226
|
-
* this the row sits at `suspended`/updated_at=suspend-time for the WHOLE resume execution, so a resume that
|
|
227
|
-
* crosses the TTL is marked `failed` mid-run and its session lock (`task_active`) is released. Returns true
|
|
228
|
-
* if it claimed the flip (false ⇒ the reaper already expired it, or it isn't suspended).
|
|
229
|
-
*/
|
|
230
135
|
async markResuming(taskId) {
|
|
231
|
-
const { affected } = await this.db.query(
|
|
232
|
-
// cancel_requested is RESET on resume: a cancel that raced the suspend (flag set but never honored —
|
|
233
|
-
// the run parked first) must not silently kill an operator-APPROVED resume later. Cancelling a suspended
|
|
234
|
-
// run is the deny-approval path; a cancel of the resumed leg sets the flag afresh. preempt_requested is
|
|
235
|
-
// RESET for the same reason (design/80 seam #2): a preempt flag that survived into the resumed leg would
|
|
236
|
-
// make the heartbeat re-preempt it immediately (you resume to make PROGRESS) — the durable twin of core's
|
|
237
|
-
// pre-aborted-signal strip; a genuine re-preempt of the resumed leg sets the flag afresh.
|
|
238
|
-
// design/80 D-B: a `needs_review` (plan_review / dry_run_review) park is resumable too — the CAS accepts it
|
|
239
|
-
// alongside `suspended` so the plan_review resume can claim the parked row. The flag-reset rationale is
|
|
240
|
-
// identical for both park kinds.
|
|
241
|
-
this.q("UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = ? WHERE task_id = ? AND status IN ('suspended','needs_review')", "UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = $1 WHERE task_id = $2 AND status IN ('suspended','needs_review')"), [new Date(), taskId]);
|
|
136
|
+
const { affected } = await this.db.query(this.q("UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = ? WHERE task_id = ? AND status IN ('suspended','needs_review')", "UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = $1 WHERE task_id = $2 AND status IN ('suspended','needs_review')"), [new Date(), taskId]);
|
|
242
137
|
return affected === 1;
|
|
243
138
|
}
|
|
244
|
-
/** The active run's taskId for a session (the `task_active` claim) — lets resume find the suspended run row. */
|
|
245
139
|
async getActiveTaskId(sessionId) {
|
|
246
140
|
const { rows } = await this.db.query(this.q("SELECT task_id FROM task_active WHERE session_id = ?", "SELECT task_id FROM task_active WHERE session_id = $1"), [sessionId]);
|
|
247
141
|
return rows[0] ? String(rows[0].task_id) : undefined;
|
|
248
142
|
}
|
|
249
|
-
/** Shared row→record mapper — byte-identical between dialects (both drivers hand back the same column
|
|
250
|
-
* names + comparable JS types after SqlDriver's result normalization). */
|
|
251
143
|
rowToRun(r) {
|
|
252
144
|
return {
|
|
253
145
|
taskId: String(r.task_id),
|
|
@@ -269,18 +161,7 @@ export class SqlRunStore {
|
|
|
269
161
|
const { rows } = await this.db.query(this.q("SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, objective_preview, created_at, updated_at FROM task_run WHERE task_id = ?", "SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, objective_preview, created_at, updated_at FROM task_run WHERE task_id = $1"), [taskId]);
|
|
270
162
|
return rows[0] ? this.rowToRun(rows[0]) : undefined;
|
|
271
163
|
}
|
|
272
|
-
/** Task Trace list (S1): runs newest-first, keyset-paginated on (created_at, task_id). Optional status +
|
|
273
|
-
* jobId filters (jobId = the work-view group, served by idx_job). Caller passes `limit+1` to detect a next
|
|
274
|
-
* page. Cursor fields are bound as params (no SQL injection).
|
|
275
|
-
*
|
|
276
|
-
* 🔴 Genuinely-divergent BUILDER (not just SQL text): TiDB's `?` is position-agnostic, so the WHERE array is
|
|
277
|
-
* built with plain string pushes in call order. PG's `$n` needs a NUMBERED placeholder per param, so that arm
|
|
278
|
-
* keeps its own positional-counter closure (`p()`) — collapsing the two into one shared counter would hide
|
|
279
|
-
* the very placeholder-numbering divergence this file exists to keep visible. */
|
|
280
164
|
async listRuns(opts) {
|
|
281
|
-
// 夜测 N1(2026-07-09,LOW):a non-date cursor.createdAt made the four backends diverge (PG threw a raw
|
|
282
|
-
// DB error; the other three returned an empty page). HTTP's decodeCursor pre-validates, so this is
|
|
283
|
-
// defence-in-depth for direct store callers — normalize ALL backends to the empty page.
|
|
284
165
|
if (opts.cursor && Number.isNaN(Date.parse(opts.cursor.createdAt)))
|
|
285
166
|
return [];
|
|
286
167
|
if (this.db.dialect === "tidb") {
|
|
@@ -299,11 +180,11 @@ export class SqlRunStore {
|
|
|
299
180
|
params.push(opts.source);
|
|
300
181
|
}
|
|
301
182
|
if (opts.owner !== undefined) {
|
|
302
|
-
where.push("owner = ?");
|
|
183
|
+
where.push("owner = ?");
|
|
303
184
|
params.push(opts.owner);
|
|
304
185
|
}
|
|
305
186
|
if (opts.cursor) {
|
|
306
|
-
where.push("(created_at < ? OR (created_at = ? AND task_id < ?))");
|
|
187
|
+
where.push("(created_at < ? OR (created_at = ? AND task_id < ?))");
|
|
307
188
|
params.push(new Date(opts.cursor.createdAt), new Date(opts.cursor.createdAt), opts.cursor.taskId);
|
|
308
189
|
}
|
|
309
190
|
const sql = "SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, objective_preview, created_at, updated_at FROM task_run" +
|
|
@@ -324,9 +205,9 @@ export class SqlRunStore {
|
|
|
324
205
|
if (opts.jobId !== undefined)
|
|
325
206
|
where.push(`job_id = ${p(opts.jobId)}`);
|
|
326
207
|
if (opts.source !== undefined)
|
|
327
|
-
where.push(`source = ${p(opts.source)}`);
|
|
208
|
+
where.push(`source = ${p(opts.source)}`);
|
|
328
209
|
if (opts.owner !== undefined)
|
|
329
|
-
where.push(`owner = ${p(opts.owner)}`);
|
|
210
|
+
where.push(`owner = ${p(opts.owner)}`);
|
|
330
211
|
if (opts.cursor) {
|
|
331
212
|
const a = p(new Date(opts.cursor.createdAt));
|
|
332
213
|
const b = p(new Date(opts.cursor.createdAt));
|
|
@@ -339,35 +220,25 @@ export class SqlRunStore {
|
|
|
339
220
|
const { rows } = await this.db.query(sql, params);
|
|
340
221
|
return rows.map((r) => this.rowToRun(r));
|
|
341
222
|
}
|
|
342
|
-
/** GET /v1/sessions (CC /resume picker): DISTINCT sessions newest-first by last activity, keyset-paginated on
|
|
343
|
-
* (last_activity, session_id). Aggregates task_run by session_id — one window pass picks the latest run per
|
|
344
|
-
* session (rn=1) for the preview/status and rolls up first/last/count. Owner-scoped when set (the per-user view).
|
|
345
|
-
* Bounded by per-tenant data today; a denormalized session table is the optimization if it ever goes hot.
|
|
346
|
-
*
|
|
347
|
-
* 🔴 Same builder divergence as {@link listRuns} — see its header note. */
|
|
348
223
|
async listSessions(opts) {
|
|
349
224
|
if (this.db.dialect === "tidb") {
|
|
350
225
|
const params = [];
|
|
351
226
|
let innerWhere = "";
|
|
352
227
|
if (opts.owner !== undefined) {
|
|
353
|
-
|
|
354
|
-
innerWhere = opts.includeUnowned ? " WHERE (owner = ? OR owner IS NULL)" : " WHERE owner = ?"; // "" is a valid exact owner, not "no filter" (B10)
|
|
228
|
+
innerWhere = opts.includeUnowned ? " WHERE (owner = ? OR owner IS NULL)" : " WHERE owner = ?";
|
|
355
229
|
params.push(opts.owner);
|
|
356
230
|
}
|
|
357
231
|
const outer = ["rn = 1"];
|
|
358
232
|
if (opts.q) {
|
|
359
|
-
// `?q=` — ANY-run preview match, session-granular (an outer LIKE on the rn=1 row alone would
|
|
360
|
-
// miss sessions whose match lives in an older run). LOWER-LIKE = collation-proof ci; escapeLike pins wildcards.
|
|
361
|
-
// Owner/sql_mode rationale: see tidb-session-store.ts listSessions.
|
|
362
233
|
outer.push("EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = t.session_id AND LOWER(tr.objective_preview) LIKE LOWER(?) ESCAPE '\\\\')");
|
|
363
234
|
params.push(`%${escapeLike(opts.q)}%`);
|
|
364
235
|
}
|
|
365
236
|
if (opts.cursor) {
|
|
366
|
-
outer.push("(last_activity < ? OR (last_activity = ? AND session_id < ?))");
|
|
237
|
+
outer.push("(last_activity < ? OR (last_activity = ? AND session_id < ?))");
|
|
367
238
|
params.push(new Date(opts.cursor.lastActivityAt), new Date(opts.cursor.lastActivityAt), opts.cursor.sessionId);
|
|
368
239
|
}
|
|
369
240
|
const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status FROM (" +
|
|
370
|
-
"SELECT session_id, owner, task_id, objective_preview, status," +
|
|
241
|
+
"SELECT session_id, owner, task_id, objective_preview, status," +
|
|
371
242
|
" ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn," +
|
|
372
243
|
" MAX(created_at) OVER (PARTITION BY session_id) AS last_activity," +
|
|
373
244
|
" MIN(created_at) OVER (PARTITION BY session_id) AS first_activity," +
|
|
@@ -383,9 +254,8 @@ export class SqlRunStore {
|
|
|
383
254
|
params.push(v);
|
|
384
255
|
return `$${params.length}`;
|
|
385
256
|
};
|
|
386
|
-
const innerWhere = opts.owner !== undefined ? (opts.includeUnowned ? ` WHERE (owner = ${p(opts.owner)} OR owner IS NULL)` : ` WHERE owner = ${p(opts.owner)}`) : "";
|
|
257
|
+
const innerWhere = opts.owner !== undefined ? (opts.includeUnowned ? ` WHERE (owner = ${p(opts.owner)} OR owner IS NULL)` : ` WHERE owner = ${p(opts.owner)}`) : "";
|
|
387
258
|
const outer = ["rn = 1"];
|
|
388
|
-
// `?q=` — the PG twin of the TiDB EXISTS predicate above.
|
|
389
259
|
if (opts.q)
|
|
390
260
|
outer.push(`EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = t.session_id AND LOWER(tr.objective_preview) LIKE LOWER(${p(`%${escapeLike(opts.q)}%`)}) ESCAPE '\\')`);
|
|
391
261
|
if (opts.cursor) {
|
|
@@ -395,7 +265,7 @@ export class SqlRunStore {
|
|
|
395
265
|
outer.push(`(last_activity < ${a} OR (last_activity = ${b} AND session_id < ${sid}))`);
|
|
396
266
|
}
|
|
397
267
|
const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status FROM (" +
|
|
398
|
-
"SELECT session_id, owner, task_id, objective_preview, status," +
|
|
268
|
+
"SELECT session_id, owner, task_id, objective_preview, status," +
|
|
399
269
|
" ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn," +
|
|
400
270
|
" MAX(created_at) OVER (PARTITION BY session_id) AS last_activity," +
|
|
401
271
|
" MIN(created_at) OVER (PARTITION BY session_id) AS first_activity," +
|
|
@@ -405,7 +275,6 @@ export class SqlRunStore {
|
|
|
405
275
|
const { rows } = await this.db.query(sql, params);
|
|
406
276
|
return rows.map((r) => this.rowToSession(r));
|
|
407
277
|
}
|
|
408
|
-
/** Shared row→summary mapper for listSessions (byte-identical between dialects). */
|
|
409
278
|
rowToSession(r) {
|
|
410
279
|
return {
|
|
411
280
|
sessionId: String(r.session_id),
|
|
@@ -415,69 +284,22 @@ export class SqlRunStore {
|
|
|
415
284
|
runCount: Number(r.run_count),
|
|
416
285
|
objectivePreview: r.objective_preview ?? null,
|
|
417
286
|
lastStatus: String(r.status),
|
|
418
|
-
lastRunId: r.task_id ?? null,
|
|
419
|
-
title: null,
|
|
287
|
+
lastRunId: r.task_id ?? null,
|
|
288
|
+
title: null,
|
|
420
289
|
};
|
|
421
290
|
}
|
|
422
|
-
/**
|
|
423
|
-
* E21 (§0.5 session delete) — purge the service-owned RUNS LEDGER for one session: task_event (the replayable
|
|
424
|
-
* event log), task_active (the single-active-run claim), and task_run (the run rows). Idempotent — returns
|
|
425
|
-
* `{ removed }` (the count of task_run rows removed; 0 = nothing to purge). The conversation HISTORY
|
|
426
|
-
* (session_meta/session_event) is purged separately by the session store; the checkpoint/tool-result rows by
|
|
427
|
-
* their stores — this method owns only the runs-ledger tables. Done in one TX so a partial purge can't leak a
|
|
428
|
-
* task_active claim with no run row (a permanent session lock).
|
|
429
|
-
*
|
|
430
|
-
* 🔴 Scoped by `session_id` ALONE — deliberately NO per-row owner guard (F-A, 2026-08-17 合并码扫描;同一条
|
|
431
|
-
* 判断 `boot/session-faces.ts` 的 anchors 腿早就写过,run 账本这条腿当时没跟上):`task_run.owner` 是**每次
|
|
432
|
-
* 提交的 principal**,它与会话的 canonical owner 会分叉 —— 一条 `session_meta.owner = NULL` 的存量会话被
|
|
433
|
-
* 带 principal 的壳续聊后(REQUIRE_PRINCIPAL=false),新 run 行的 owner 是 `"default"`,而 DELETE 路由递进来
|
|
434
|
-
* 的是**会话行**的 owner(NULL)。行级 null-safe 属主门(`<=>` / `IS NOT DISTINCT FROM`)于是只删得掉 NULL
|
|
435
|
-
* 那批,具名那批连同它们的 task_event 全部存活 —— 而 `session_meta` 已经删了,留存腿的 sessionCandidates
|
|
436
|
-
* 再也枚举不到这棵树 ⇒ 孤儿行**没有任何清理路径**,路由却回 `{deleted:true}`。那就是一次**报成成功的
|
|
437
|
-
* 右删失效**(anchors 腿的注逐字写着这句)。删除决策的属主门在**路由层**(DELETE /v1/sessions/:id 先
|
|
438
|
-
* `ownerOf` 再比对 scope,fleet-wide 臂同门),本方法的唯一生产调用点是那条路由后面的 purge 协调器。
|
|
439
|
-
*
|
|
440
|
-
* 🔴 **化身围栏**(`expectedSessionOwner`;codex 对抗复审 R1-[high],验真后修)—— 属主门下来了,但会话
|
|
441
|
-
* **身份**必须在同一个事务里重新确认。会话 id 由调用方自选、且删除之后**可被别人重新登记**
|
|
442
|
-
* (`register` 是 INSERT … ON DUPLICATE KEY / ON CONFLICT,老行没了就是一条新行,新 owner):于是
|
|
443
|
-
* 「路由 `ownerOf` 已验权」这句话在两只并发 purge 交错时会过期 —— A 与 A' 同时通过属主门,A' 跑完整条
|
|
444
|
-
* purge 把 meta 删掉,租户 B 用同一个 uuid 重新登记并跑完一次 run,慢半拍的 A 才走到这里 ⇒ 按 session_id
|
|
445
|
-
* 单键删就把 **B 的**行删了(修前的行级属主门恰好挡住这一形,因为 B 的行 owner≠A 的会话主)。
|
|
446
|
-
* ⇒ 事务头部 `SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE`,行不在或 owner 与路由验过的
|
|
447
|
-
* 那一位不符 ⇒ **一行不删**回 `{removed:0}`(诚实:属于调用方的那个化身已经不在了;协调器据此回
|
|
448
|
-
* `{deleted:false}`,不会拿别人的行数冒充成功)。锁序与留存腿逐字一致(先 session_meta 后 task_*),
|
|
449
|
-
* 不引入新的死锁环;锁在本事务提交时释放,其后的 checkpoint / history 两腿各自带属主门。
|
|
450
|
-
* ⚠️ 本围栏只有 SQL 双生做得到 —— File/Memory 孪生的账本里没有 session_meta(local 车道的会话表在另一只
|
|
451
|
-
* 店里),它们的同名参数因此只作**契约对齐**用,真围栏由协调器那条 pre-delete 复读 + local 数据根的
|
|
452
|
-
* 独占 BootLock(一个数据根恒一个进程)承担;逐字见 memory-run-store 的同名方法注。
|
|
453
|
-
*
|
|
454
|
-
* 🔴 Active-run re-assert INSIDE the TX (adversarial-review LOW — TOCTOU): the route's pre-purge `getActiveTaskId`
|
|
455
|
-
* 409 check is a non-transactional SELECT — a concurrent createRun can claim `task_active(session_id)` in the
|
|
456
|
-
* window between that check and this purge, then have its LIVE rows deleted here. So we re-take the no-active-run
|
|
457
|
-
* invariant transactionally: `SELECT task_id FROM task_active WHERE session_id=? FOR UPDATE` at the TOP of the TX.
|
|
458
|
-
* `FOR UPDATE` serializes against createRun's unique-key INSERT on the SAME session_id (createRun either already
|
|
459
|
-
* holds the row — we see it and abort — or blocks on our lock until commit, then fails its own unique-key claim
|
|
460
|
-
* against the now-absent row). If a claim exists we return `{ active }` (the route maps it to 409) and delete
|
|
461
|
-
* NOTHING. Returns `{ removed }` on a clean purge.
|
|
462
|
-
*
|
|
463
|
-
* 🔴 The multi-table DELETEs are dialect-divergent SQL FORMS (not just placeholders): TiDB's
|
|
464
|
-
* `DELETE te FROM te JOIN tr ON …` vs PG's `DELETE FROM te USING tr WHERE …`.
|
|
465
|
-
*/
|
|
466
291
|
async deleteBySession(sessionId, expectedSessionOwner) {
|
|
467
292
|
let result = { removed: 0 };
|
|
468
293
|
await this.tx(async (conn) => {
|
|
469
|
-
// 化身围栏(见头注):路由验过的那个会话化身必须**此刻仍在**,否则一行不删。
|
|
470
294
|
const { rows: meta } = await conn.query(this.q("SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE", "SELECT owner FROM session_meta WHERE session_id = $1 FOR UPDATE"), [sessionId]);
|
|
471
295
|
const metaRow = meta[0];
|
|
472
296
|
if (metaRow === undefined || (metaRow.owner ?? null) !== expectedSessionOwner)
|
|
473
|
-
return;
|
|
474
|
-
// Transactional no-active-run gate (FOR UPDATE serializes against createRun's task_active claim).
|
|
297
|
+
return;
|
|
475
298
|
const { rows: active } = await conn.query(this.q("SELECT task_id FROM task_active WHERE session_id = ? FOR UPDATE", "SELECT task_id FROM task_active WHERE session_id = $1 FOR UPDATE"), [sessionId]);
|
|
476
299
|
if (active[0]) {
|
|
477
300
|
result = { active: String(active[0].task_id) };
|
|
478
|
-
return;
|
|
301
|
+
return;
|
|
479
302
|
}
|
|
480
|
-
// task_event has no session_id column → scope it through this session's task_run set (session_id alone).
|
|
481
303
|
await conn.query(this.q("DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE tr.session_id = ?", "DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND tr.session_id = $1"), [sessionId]);
|
|
482
304
|
await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.session_id = ?", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.session_id = $1"), [sessionId]);
|
|
483
305
|
const { affected } = await conn.query(this.q("DELETE FROM task_run WHERE session_id = ?", "DELETE FROM task_run WHERE session_id = $1"), [sessionId]);
|
|
@@ -485,15 +307,10 @@ export class SqlRunStore {
|
|
|
485
307
|
});
|
|
486
308
|
return result;
|
|
487
309
|
}
|
|
488
|
-
/** Per-door usage rollup (center systems-UI input): COUNT by (source,status) since a cutoff. Reads the
|
|
489
|
-
* denormalized columns only — no JSON parsing; served by full scan bounded by created_at (small windows). */
|
|
490
310
|
async sourceSummary(sinceMs) {
|
|
491
311
|
const { rows } = await this.db.query(this.q("SELECT source, status, COUNT(*) AS c FROM task_run WHERE created_at >= ? GROUP BY source, status", "SELECT source, status, COUNT(*) AS c FROM task_run WHERE created_at >= $1 GROUP BY source, status"), [new Date(sinceMs)]);
|
|
492
312
|
return rows.map((r) => ({ source: r.source ?? null, status: String(r.status), count: Number(r.c) }));
|
|
493
313
|
}
|
|
494
|
-
/** 用量扫窗(usage-analytics):按时间窗取 {owner,created_at,status,result} 四列,行内投影
|
|
495
|
-
* stats(JSON 提取在 JS,免 SQL JSON 方言分叉);LIMIT+1 探测截断(触顶=truncated:true 诚实上抛)。
|
|
496
|
-
* 聚合在 src/usage-analytics.ts 纯函数(三后端同代码)。可选 owner 过滤=JWT 主体只看自己。 */
|
|
497
314
|
async usageScan(fromMs, toMs, opts) {
|
|
498
315
|
const limit = Math.min(opts?.limit ?? USAGE_SCAN_LIMIT, USAGE_SCAN_LIMIT);
|
|
499
316
|
const where = [this.q("created_at >= ?", "created_at >= $1"), this.q("created_at < ?", "created_at < $2")];
|
|
@@ -515,14 +332,11 @@ export class SqlRunStore {
|
|
|
515
332
|
})),
|
|
516
333
|
};
|
|
517
334
|
}
|
|
518
|
-
/** Earliest retained event seq for a task (0 if none) → `retainedFrom` for /turns and the 416 boundary for
|
|
519
|
-
* /stream (a resume point below this has been evicted past retention). */
|
|
520
335
|
async retainedFrom(taskId) {
|
|
521
336
|
const { rows } = await this.db.query(this.q("SELECT MIN(seq) AS m FROM task_event WHERE task_id = ?", "SELECT MIN(seq) AS m FROM task_event WHERE task_id = $1"), [taskId]);
|
|
522
337
|
const m = rows[0]?.m;
|
|
523
338
|
return m == null ? 0 : Number(m);
|
|
524
339
|
}
|
|
525
|
-
/** Events with seq strictly greater than `afterSeq` (use 0 for the whole stream). */
|
|
526
340
|
async getEvents(taskId, afterSeq) {
|
|
527
341
|
const { rows } = await this.db.query(this.q("SELECT seq, type, data, ts FROM task_event WHERE task_id = ? AND seq > ? ORDER BY seq ASC", "SELECT seq, type, data, ts FROM task_event WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC"), [taskId, afterSeq]);
|
|
528
342
|
return rows.map((r) => ({
|
|
@@ -532,12 +346,6 @@ export class SqlRunStore {
|
|
|
532
346
|
ts: iso(r.ts),
|
|
533
347
|
}));
|
|
534
348
|
}
|
|
535
|
-
/**
|
|
536
|
-
* Mark stale `running` rows as failed (best-effort recovery for runs whose instance died), then
|
|
537
|
-
* free any active-run claim whose run is no longer running (reaped here, or a terminal whose
|
|
538
|
-
* DELETE was lost). `olderThanMs` must exceed the longest expected gap between a run's events
|
|
539
|
-
* (each event refreshes `updated_at`), else a healthy slow run is wrongly reaped.
|
|
540
|
-
*/
|
|
541
349
|
async reapStale(olderThanMs) {
|
|
542
350
|
const cutoff = new Date(Date.now() - olderThanMs);
|
|
543
351
|
let reaped = 0;
|
|
@@ -546,38 +354,11 @@ export class SqlRunStore {
|
|
|
546
354
|
"WHERE status = 'running' AND updated_at < ?", "UPDATE task_run SET status = 'failed', error = 'run stalled (instance lost?)', updated_at = $1 " +
|
|
547
355
|
"WHERE status = 'running' AND updated_at < $2"), [new Date(), cutoff]);
|
|
548
356
|
reaped = affected;
|
|
549
|
-
// Release task_active for TERMINAL rows only. A 'suspended' row (durable F4) intentionally KEEPS its
|
|
550
|
-
// claim — the session stays locked until an operator resumes it or reapSuspended expires it. (Was
|
|
551
|
-
// `<> 'running'`, which would have prematurely unlocked a parked suspended session.)
|
|
552
357
|
await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.status NOT IN ('running','suspended','needs_review')", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.status NOT IN ('running','suspended','needs_review')"));
|
|
553
|
-
// [2255]③ 孤儿 claim 清扫(纵深防御):claim 无对应 task_run 行时,三处 INNER JOIN 释放腿永远碰不到
|
|
554
|
-
// 它。正常路径不产孤儿(createRun 单事务、setTerminal 无条件 DELETE、deleteBySession 同事务对称)——
|
|
555
|
-
// 这形只来自带外(手工 SQL/半截迁移),但一旦出现该 session 只剩 cancel 一条自救路。挂在 reapStale
|
|
556
|
-
// (每 tick 无条件跑)一处即可。同文双方言(关联 NOT EXISTS 指向**另一张**表,两引擎都合法);
|
|
557
|
-
// 无竞态:createRun 的 claim+row 同事务提交,已提交的 claim 必有行。
|
|
558
358
|
await conn.query(this.q("DELETE FROM task_active WHERE NOT EXISTS (SELECT 1 FROM task_run tr WHERE tr.task_id = task_active.task_id)", "DELETE FROM task_active WHERE NOT EXISTS (SELECT 1 FROM task_run tr WHERE tr.task_id = task_active.task_id)"));
|
|
559
359
|
});
|
|
560
360
|
return reaped;
|
|
561
361
|
}
|
|
562
|
-
/**
|
|
563
|
-
* Durable F4 expiry: an approval parked beyond `olderThanMs` (the approval TTL) was never answered → fail
|
|
564
|
-
* the run + RELEASE its task_active (unlock the session). Mirrors the checkpoint reaper's CAS-expire (≈ deny);
|
|
565
|
-
* idempotent across replicas. Returns count.
|
|
566
|
-
*
|
|
567
|
-
* 🔴 design/80 D-D (adversarial blocker fix): SKIPS any row whose checkpoint is STILL pending — that row is
|
|
568
|
-
* owned by a checkpoint-state-driven sweep (the SLA deny-sweep for human/irreversible_ask, or
|
|
569
|
-
* failSuspendedWithExpiredCheckpoint once the checkpoint is expired). Without this guard, this time-based
|
|
570
|
-
* UPDATE fired at the SAME instant as the deny-sweep (deadline == createdAt + ttl, both keyed on the TTL),
|
|
571
|
-
* almost always committed FIRST (it is a single UPDATE), and deleted task_active + failed the run BEFORE the
|
|
572
|
-
* deny-sweep's resumeCheckpoint ran — so resumeCheckpoint saw no task row (markResuming skipped) yet STILL ran
|
|
573
|
-
* the model on a now-unlocked session (the pending checkpoint's resolve-CAS wins), orphaning a model leg and
|
|
574
|
-
* opening a two-runs-one-session window. The NOT-EXISTS-pending guard (same shape as
|
|
575
|
-
* failSuspendedWithExpiredCheckpoint) means this only ever reaps a row whose checkpoint is already gone/expired.
|
|
576
|
-
*
|
|
577
|
-
* 🔴 Alias-qualification divergence (kept EXPLICIT, never "fixed" to match): TiDB's SET clause QUALIFIES with
|
|
578
|
-
* `tr.` (`SET tr.status = …`); PG's SET clause must NOT be alias-qualified (`SET status = …` — PG rejects
|
|
579
|
-
* `SET a.col`). Both WHERE clauses use the `tr.` alias.
|
|
580
|
-
*/
|
|
581
362
|
async reapSuspended(olderThanMs) {
|
|
582
363
|
const cutoff = new Date(Date.now() - olderThanMs);
|
|
583
364
|
let reaped = 0;
|
|
@@ -592,26 +373,10 @@ export class SqlRunStore {
|
|
|
592
373
|
});
|
|
593
374
|
return reaped;
|
|
594
375
|
}
|
|
595
|
-
/**
|
|
596
|
-
* design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a
|
|
597
|
-
* suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its
|
|
598
|
-
* absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
|
|
599
|
-
* approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row
|
|
600
|
-
* `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
|
|
601
|
-
* either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two
|
|
602
|
-
* windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its
|
|
603
|
-
* checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted
|
|
604
|
-
* a NEW pending checkpoint — the `NOT EXISTS pending` clause excludes it. Idempotent across replicas. Returns count.
|
|
605
|
-
*
|
|
606
|
-
* Same alias-qualification divergence as {@link reapSuspended} — see its header note.
|
|
607
|
-
*/
|
|
608
376
|
async failSuspendedWithExpiredCheckpoint() {
|
|
609
377
|
let reaped = 0;
|
|
610
378
|
await this.tx(async (conn) => {
|
|
611
379
|
const { affected } = await conn.query(this.q("UPDATE task_run tr SET tr.status = 'failed', tr.error = 'approval expired before decision', tr.error_code = 'approval.expired', tr.updated_at = ? " +
|
|
612
|
-
// wq64gmm5e HIGH: a needs_review park is ALSO a non-terminal claim-keeping state; an abandoned review
|
|
613
|
-
// park whose checkpoint reapExpired already expired must be failed + its task_active released, exactly
|
|
614
|
-
// like a suspended park — else the run row + session lock leak forever (no other reaper targets it).
|
|
615
380
|
"WHERE tr.status IN ('suspended','needs_review') " +
|
|
616
381
|
"AND EXISTS (SELECT 1 FROM checkpoint ce WHERE ce.session_id = tr.session_id AND ce.status = 'expired') " +
|
|
617
382
|
"AND NOT EXISTS (SELECT 1 FROM checkpoint cp WHERE cp.session_id = tr.session_id AND cp.status = 'pending')", "UPDATE task_run AS tr SET status = 'failed', error = 'approval expired before decision', error_code = 'approval.expired', updated_at = $1 " +
|
|
@@ -624,13 +389,11 @@ export class SqlRunStore {
|
|
|
624
389
|
return reaped;
|
|
625
390
|
}
|
|
626
391
|
}
|
|
627
|
-
/** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
|
|
628
392
|
export class TiDBRunStore extends SqlRunStore {
|
|
629
393
|
constructor(pool) {
|
|
630
394
|
super(mysqlDriver(pool));
|
|
631
395
|
}
|
|
632
396
|
}
|
|
633
|
-
/** PostgreSQL binding — historical class name + ctor shape preserved. */
|
|
634
397
|
export class PgRunStore extends SqlRunStore {
|
|
635
398
|
constructor(pool) {
|
|
636
399
|
super(pgDriver(pool));
|
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal AWS SigV4 **presigned URL** generator for S3-compatible object stores (MinIO) — zero dependencies
|
|
3
|
-
* (crypto only), matching the repo's no-SDK posture (OTLP exporter, the k8s adapter). We only need presign:
|
|
4
|
-
* the k8s sandbox adapter uploads/downloads a workspace snapshot by handing the SANDBOX a short-lived,
|
|
5
|
-
* single-object URL so the adapter's MinIO credentials never enter the sandbox (design/53 lethal-trifecta
|
|
6
|
-
* red line — "adapter-held, sandbox sees only a presigned URL").
|
|
7
|
-
*
|
|
8
|
-
* Path-style addressing (`<endpoint>/<bucket>/<key>`) — MinIO's default and what an IP/cluster-DNS endpoint
|
|
9
|
-
* needs (no virtual-host bucket subdomain). Payload is UNSIGNED-PAYLOAD so a streamed `curl -T -` body
|
|
10
|
-
* (chunked, unknown length) verifies — the signature binds method+path+headers+expiry, not the bytes.
|
|
11
|
-
*/
|
|
12
1
|
import { createHash, createHmac } from "node:crypto";
|
|
13
|
-
/** URI-encode per RFC 3986 (S3 canonicalization): unreserved chars stay literal, everything else %XX. */
|
|
14
2
|
function uriEncode(s, encodeSlash) {
|
|
15
3
|
let out = "";
|
|
16
4
|
for (const ch of Buffer.from(s, "utf8")) {
|
|
@@ -30,40 +18,22 @@ function uriEncode(s, encodeSlash) {
|
|
|
30
18
|
function hmac(key, data) {
|
|
31
19
|
return createHmac("sha256", key).update(data, "utf8").digest();
|
|
32
20
|
}
|
|
33
|
-
/** The shared SigV4 signing core: everything below the canonical-URI choice is identical for an
|
|
34
|
-
* object URL and a bucket URL — one implementation so the two public faces can never drift.
|
|
35
|
-
*
|
|
36
|
-
* Endpoint PATH PREFIX (三路复审修1): an endpoint like `https://files.example.com/minio` (reverse-proxy
|
|
37
|
-
* sub-path in front of MinIO) contributes its path to BOTH the canonical URI (what SigV4 signs) and the
|
|
38
|
-
* returned URL — previously only `url.origin` was used, so the signed canonical URI (`/bucket/key`) never
|
|
39
|
-
* matched the request path the store actually saw (`/minio/bucket/key`), and the presigned track silently
|
|
40
|
-
* diverged from the permanent track's plain string concat (which DID keep the prefix). One rstrip'd
|
|
41
|
-
* pathname, prepended to both, keeps signature and request path congruent for any endpoint shape.
|
|
42
|
-
*
|
|
43
|
-
* `signedHeaders` (修3 CopyObject support): extra headers to SIGN beyond `host` — presigned-URL (query)
|
|
44
|
-
* auth requires every `x-amz-*` header the request carries to be in X-Amz-SignedHeaders, so a server-side
|
|
45
|
-
* CopyObject (`x-amz-copy-source`) must sign it. Names are lowercased; canonical headers are sorted;
|
|
46
|
-
* the CALLER must send exactly these header values on the request. */
|
|
47
21
|
function presignCanonical(p) {
|
|
48
22
|
const region = p.region ?? "us-east-1";
|
|
49
23
|
const expires = p.expiresSec ?? 600;
|
|
50
24
|
const now = p.now ?? new Date();
|
|
51
|
-
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
25
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
52
26
|
const dateStamp = amzDate.slice(0, 8);
|
|
53
27
|
const url = new URL(p.endpoint);
|
|
54
|
-
const host = url.host;
|
|
55
|
-
// Endpoint path prefix (修1): "" for a root endpoint; "/minio" for a sub-path endpoint (trailing "/"
|
|
56
|
-
// stripped; `new URL().pathname` is already percent-encoded, which is what the canonical URI wants).
|
|
28
|
+
const host = url.host;
|
|
57
29
|
const basePath = url.pathname.replace(/\/+$/, "");
|
|
58
30
|
const canonicalUri = `${basePath}${p.canonicalUri}`;
|
|
59
31
|
const scope = `${dateStamp}/${region}/s3/aws4_request`;
|
|
60
|
-
// Signed headers: `host` always; extras (lowercased, value-trimmed) merged in; canonical order = sorted.
|
|
61
32
|
const headerMap = new Map([["host", host]]);
|
|
62
33
|
for (const [k, v] of Object.entries(p.signedHeaders ?? {}))
|
|
63
34
|
headerMap.set(k.toLowerCase(), v.trim());
|
|
64
35
|
const headerNames = [...headerMap.keys()].sort();
|
|
65
36
|
const signedHeaderList = headerNames.join(";");
|
|
66
|
-
// Query params must be sorted; values URI-encoded (the credential's "/" too).
|
|
67
37
|
const params = {
|
|
68
38
|
...(p.extraQuery ?? {}),
|
|
69
39
|
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
|
@@ -86,29 +56,18 @@ function presignCanonical(p) {
|
|
|
86
56
|
const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
|
|
87
57
|
return `${url.origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
|
|
88
58
|
}
|
|
89
|
-
/** Build a SigV4 presigned **single-object** URL. Pure string math — no network. */
|
|
90
59
|
export function presignS3Url(p) {
|
|
91
|
-
// codex 1.189 F8: the contract here is SINGLE-OBJECT — an empty key would silently sign the BUCKET URI
|
|
92
|
-
// (`/<bucket>/` = CreateBucket/DeleteBucket/ListObjects depending on method), a far broader capability
|
|
93
|
-
// than any caller of this face intends. Refuse; bucket-level operations go through presignS3BucketUrl.
|
|
94
60
|
if (p.key === "") {
|
|
95
61
|
throw new Error("presignS3Url: refusing to sign an empty key (single-object contract) — use presignS3BucketUrl for bucket-level operations");
|
|
96
62
|
}
|
|
97
|
-
// Backstop (defense in depth): callers MUST pass a pre-sanitized key, but a path-traversal segment here would
|
|
98
|
-
// sign a URL that escapes its intended prefix once the store normalizes the path — refuse rather than sign it.
|
|
99
63
|
if (p.key.startsWith("/") || p.key.split("/").some((seg) => seg === "." || seg === "..")) {
|
|
100
64
|
throw new Error(`presignS3Url: refusing to sign a key with a traversal segment: ${p.key}`);
|
|
101
65
|
}
|
|
102
66
|
return presignCanonical({ ...p, canonicalUri: `/${uriEncode(p.bucket, true)}/${uriEncode(p.key, false)}` });
|
|
103
67
|
}
|
|
104
|
-
/** Build a SigV4 presigned **bucket-level** URL (`/<bucket>/`, no key) — the explicit face for
|
|
105
|
-
* CreateBucket/DeleteBucket (codex 1.189 F8: previously test fixtures smuggled this through
|
|
106
|
-
* presignS3Url with key "", which the single-object contract now rejects). Same signing core. */
|
|
107
68
|
export function presignS3BucketUrl(p) {
|
|
108
69
|
return presignCanonical({ ...p, canonicalUri: `/${uriEncode(p.bucket, true)}/` });
|
|
109
70
|
}
|
|
110
|
-
/** ListObjectsV2 presign(D-1 孤儿对象 GC,clay 拍 2026-07-28):GET `/<bucket>/?list-type=2&prefix=…`。
|
|
111
|
-
* bucket 级 GET 是**列举能力**——只给 server 端 sweep 用,URL 绝不外发(与 copy 面同纪律)。 */
|
|
112
71
|
export function presignS3ListUrl(p) {
|
|
113
72
|
return presignCanonical({
|
|
114
73
|
endpoint: p.endpoint,
|
|
@@ -127,12 +86,6 @@ export function presignS3ListUrl(p) {
|
|
|
127
86
|
},
|
|
128
87
|
});
|
|
129
88
|
}
|
|
130
|
-
/** Build a SigV4 presigned **CopyObject** request (修3 staging-key two-phase): S3 CopyObject = `PUT
|
|
131
|
-
* /<dstBucket>/<dstKey>` with `x-amz-copy-source: /<srcBucket>/<srcKey>`. Query auth requires every
|
|
132
|
-
* `x-amz-*` request header to be SIGNED, so the copy-source header goes through the signing core's
|
|
133
|
-
* signedHeaders seam; the caller MUST send `headers` verbatim on the fetch (the source key is
|
|
134
|
-
* RFC-3986-encoded — MinIO/S3 both accept the encoded copy-source form, and header values must be
|
|
135
|
-
* ASCII anyway). Same single-object guards as presignS3Url on BOTH sides. */
|
|
136
89
|
export function presignS3CopyUrl(p) {
|
|
137
90
|
for (const [label, key] of [
|
|
138
91
|
["destination", p.key],
|