@sema-agent/server 7.124.1 → 7.125.1
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 +138 -2
- package/USAGE.md +46 -8
- package/dist/audit.d.ts +27 -5
- package/dist/audit.js +42 -8
- package/dist/boot/execution-env.d.ts +33 -1
- package/dist/boot/execution-env.js +31 -2
- package/dist/boot/resolve-spec.js +4 -3
- package/dist/boot/session-faces.d.ts +14 -1
- package/dist/boot/session-faces.js +6 -5
- package/dist/boot/stage-02-web-admin-pull.js +5 -1
- package/dist/boot/stage-05-execution-env.js +6 -2
- package/dist/boot/stage-09-leader.d.ts +1 -1
- package/dist/boot/stage-10-http-server.d.ts +1 -1
- package/dist/boot/web-admin.js +130 -140
- package/dist/capabilities/center-wiring.d.ts +2 -1
- package/dist/capabilities/composite-members.d.ts +1 -0
- package/dist/capabilities/composite-members.js +1 -0
- package/dist/capability-facts.d.ts +28 -3
- package/dist/capability-facts.js +20 -0
- package/dist/config-catalog.js +4 -1
- package/dist/config-d-migration.js +2 -1
- package/dist/config-lkg.d.ts +4 -1
- package/dist/config-lkg.js +16 -12
- package/dist/config-provider.d.ts +4 -1
- package/dist/config-provider.js +1 -0
- package/dist/config-types.d.ts +10 -3
- package/dist/config.js +44 -14
- package/dist/env-facts.d.ts +2 -2
- package/dist/env-facts.js +3 -2
- package/dist/fleet/fleet-bus.d.ts +24 -12
- package/dist/fleet/fleet-bus.js +4 -1
- package/dist/fleet/workspace-snapshot-residual.d.ts +10 -0
- package/dist/fleet/workspace-snapshot-residual.js +35 -0
- package/dist/hooks/hook-runner.d.ts +15 -27
- package/dist/hooks/hook-runner.js +19 -59
- package/dist/http/active-run-conflict.d.ts +3 -0
- package/dist/http/active-run-conflict.js +3 -0
- package/dist/http/admission.js +7 -7
- package/dist/http/approval-sweeps.js +2 -4
- package/dist/http/device-placement.js +1 -0
- package/dist/http/dispatch.js +1 -1
- package/dist/http/durable-run-leg.d.ts +20 -6
- package/dist/http/durable-run-leg.js +4 -4
- package/dist/http/idempotency.d.ts +83 -38
- package/dist/http/idempotency.js +38 -13
- package/dist/http/request-guards.d.ts +18 -0
- package/dist/http/request-guards.js +12 -3
- package/dist/http/resume-legs.js +7 -1
- package/dist/http/route-ctx.d.ts +3 -3
- package/dist/http/route-ctx.js +1 -1
- package/dist/http/routes/a2a-serve.js +27 -11
- package/dist/http/routes/capabilities.js +1 -0
- package/dist/http/routes/fleet.js +2 -0
- package/dist/http/routes/images.js +2 -2
- package/dist/http/routes/leader.d.ts +4 -0
- package/dist/http/routes/leader.js +13 -3
- package/dist/http/routes/observability.js +2 -0
- package/dist/http/routes/runs.d.ts +6 -2
- package/dist/http/routes/runs.js +71 -101
- package/dist/http/routes/session-sync.d.ts +3 -0
- package/dist/http/routes/session-sync.js +30 -1
- package/dist/http/routes/sessions-list.d.ts +11 -0
- package/dist/http/routes/sessions-list.js +6 -6
- package/dist/http/routes/sessions.d.ts +6 -1
- package/dist/http/routes/sessions.js +41 -10
- package/dist/http/routes/tasks.js +63 -21
- package/dist/http/run-read-face.d.ts +26 -0
- package/dist/http/run-read-face.js +67 -0
- package/dist/http/server.d.ts +4 -4
- package/dist/http/server.js +1 -2
- package/dist/http/submit-replay.d.ts +40 -0
- package/dist/http/submit-replay.js +22 -0
- package/dist/leader/endpoint.d.ts +10 -5
- package/dist/leader/endpoint.js +27 -5
- package/dist/observability/cost-quota.d.ts +51 -7
- package/dist/observability/cost-quota.js +35 -17
- package/dist/observability/fail-open.d.ts +1 -1
- package/dist/observability/fail-open.js +1 -1
- package/dist/observability/run-terminal.d.ts +5 -9
- package/dist/observability/run-terminal.js +2 -7
- package/dist/plugins/approval-ask-store-sql.js +1 -1
- package/dist/plugins/caching-session-store.d.ts +7 -2
- package/dist/plugins/caching-session-store.js +6 -0
- package/dist/plugins/device-store-sql.js +1 -1
- package/dist/plugins/ensure-index.js +22 -0
- package/dist/plugins/file-run-store.d.ts +24 -14
- package/dist/plugins/file-run-store.js +52 -12
- package/dist/plugins/leader-run-store-sql.d.ts +1 -11
- package/dist/plugins/leader-run-store-sql.js +1 -5
- package/dist/plugins/local-session-store.d.ts +35 -6
- package/dist/plugins/local-session-store.js +87 -18
- package/dist/plugins/memory-run-store.d.ts +13 -12
- package/dist/plugins/memory-run-store.js +25 -0
- package/dist/plugins/one-time-migrations.js +1 -1
- package/dist/plugins/pg-cost-quota.d.ts +1 -1
- package/dist/plugins/pg-cost-quota.js +2 -5
- package/dist/plugins/pg-pool.js +3 -0
- package/dist/plugins/pg-session-storage.d.ts +11 -1
- package/dist/plugins/pg-session-storage.js +44 -8
- package/dist/plugins/remote-env-k8s.d.ts +127 -12
- package/dist/plugins/remote-env-k8s.js +270 -27
- package/dist/plugins/run-store-sql.d.ts +26 -12
- package/dist/plugins/run-store-sql.js +50 -25
- package/dist/plugins/schema-consistency.d.ts +1 -13
- package/dist/plugins/schema-consistency.js +1 -41
- package/dist/plugins/schema-indexes.js +1 -0
- package/dist/plugins/schema-live-columns.d.ts +20 -0
- package/dist/plugins/schema-live-columns.js +42 -0
- package/dist/plugins/store-contracts.d.ts +40 -2
- package/dist/plugins/tidb-cost-quota.d.ts +1 -1
- package/dist/plugins/tidb-cost-quota.js +2 -5
- package/dist/plugins/tidb-pool.js +14 -3
- package/dist/plugins/tidb-session-store.d.ts +22 -2
- package/dist/plugins/tidb-session-store.js +33 -6
- package/dist/plugins/write-behind-counter.d.ts +12 -5
- package/dist/plugins/write-behind-counter.js +14 -7
- package/dist/prompts-domain-validate.js +3 -2
- package/dist/rules-consent.d.ts +2 -2
- package/dist/rules-consent.js +2 -2
- package/dist/run-local.js +31 -23
- package/dist/runs.d.ts +9 -0
- package/dist/runs.js +8 -0
- package/dist/runtime-governance.d.ts +3 -3
- package/dist/runtime-governance.js +2 -4
- package/dist/security.d.ts +19 -2
- package/dist/session-row.d.ts +53 -0
- package/dist/session-row.js +47 -0
- package/dist/session-sync.d.ts +6 -0
- package/dist/session-sync.js +5 -1
- package/dist/session-titler.d.ts +9 -5
- package/dist/session-titler.js +3 -1
- package/dist/spec-fields.js +2 -2
- package/dist/task-cwd.d.ts +0 -20
- package/dist/task-cwd.js +2 -10
- package/dist/tool-approval.d.ts +5 -7
- package/dist/trace/redact.d.ts +23 -50
- package/dist/trace/redact.js +42 -9
- package/dist/trace/self-minted-wire.d.ts +25 -0
- package/dist/trace/self-minted-wire.js +9 -0
- package/dist/web-admin-client/apply-effective.d.ts +10 -12
- package/dist/web-admin-client/apply-effective.js +2 -3
- package/dist/web-admin-client/candidate-gate.d.ts +69 -0
- package/dist/web-admin-client/candidate-gate.js +27 -0
- package/dist/web-admin-client/http-client.d.ts +2 -1
- package/dist/web-admin-client/http-client.js +2 -1
- package/dist/web-admin-client/pull-state.d.ts +6 -4
- package/dist/web-admin-client/read-warnings.d.ts +29 -5
- package/dist/web-admin-client/read-warnings.js +24 -4
- package/dist/web-admin-client/stage-limits.js +4 -1
- package/dist/workspace-space.d.ts +41 -0
- package/dist/workspace-space.js +5 -0
- package/package.json +3 -3
|
@@ -2,7 +2,7 @@ import type { TaskStatus } from "@sema-agent/core";
|
|
|
2
2
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
3
3
|
import type { Pool as PgPool } from "pg";
|
|
4
4
|
import { type UsageRow } from "../usage-analytics.js";
|
|
5
|
-
import { type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
|
|
5
|
+
import { type CreateRunOutcome, type NewRunMeta, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
|
|
6
6
|
import { type SqlDriver } from "./sql-driver.js";
|
|
7
7
|
import type { LedgerEventType } from "../trace/ledger-events.js";
|
|
8
8
|
export type { RunRecord, SessionSummary, RunEvent, LatestRunRef } from "./store-contracts.js";
|
|
@@ -20,23 +20,27 @@ export declare class SqlRunStore {
|
|
|
20
20
|
/** Run statements atomically; rolls back on failure. */
|
|
21
21
|
private tx;
|
|
22
22
|
/**
|
|
23
|
-
* Create a run, but only if the session has no other active run (double-text guard)
|
|
23
|
+
* Create a run, but only if the session has no other active run (double-text guard) and no submission key
|
|
24
|
+
* of this request already names a row (submission idempotency) — **atomic win or observe**.
|
|
24
25
|
*
|
|
25
26
|
* Mutual exclusion is a unique-key claim on `task_active(session_id)` — NOT a SELECT…FOR UPDATE,
|
|
26
27
|
* which on TiDB (no gap locks) would fail to serialize two concurrent createRuns when the session
|
|
27
28
|
* has no prior row. A plain INSERT either claims the session or throws a duplicate-key error (TiDB
|
|
28
29
|
* does NOT report a reliable affectedRows for `ON DUPLICATE KEY UPDATE …=…`, so we catch dup-key).
|
|
30
|
+
*
|
|
31
|
+
* Submission idempotency rides the SAME mechanism on the row itself: `task_run` carries two unique keys a
|
|
32
|
+
* submission can name — `task_id` (a caller-minted `body.taskId`) and `idem_key` (the scoped
|
|
33
|
+
* `Idempotency-Key`; NULL = none, which neither dialect's UNIQUE constrains). Claim + row are ONE
|
|
34
|
+
* transaction, so a concurrent same-key submit on another replica either commits first (we collide on the
|
|
35
|
+
* unique index — after its commit, PG/InnoDB/TiDB-pessimistic block on the uncommitted entry; TiDB-optimistic
|
|
36
|
+
* reports it at COMMIT, which is why the commit sits inside the same classification) or loses to us.
|
|
37
|
+
*
|
|
38
|
+
* Any dup-key ⇒ roll back (our claim goes with it) ⇒ OBSERVE, keys first: a row named by `idem_key`, else by
|
|
39
|
+
* `task_id` ⇒ `{ ok:false, existing }` (no new row; the submission surface renders the receipt); otherwise the
|
|
40
|
+
* session claim was the collision ⇒ `{ ok:false, activeTaskId }`. A dup on the ROW insert that names no
|
|
41
|
+
* observable row is not attributable to either key ⇒ rethrown (loud), never reported as a busy session.
|
|
29
42
|
*/
|
|
30
|
-
createRun(taskId: string, sessionId: string, owner: string | null, instanceId: string, meta?:
|
|
31
|
-
jobId?: string | null;
|
|
32
|
-
source?: string | null;
|
|
33
|
-
objectivePreview?: string | null;
|
|
34
|
-
}): Promise<{
|
|
35
|
-
ok: true;
|
|
36
|
-
} | {
|
|
37
|
-
ok: false;
|
|
38
|
-
activeTaskId: string;
|
|
39
|
-
}>;
|
|
43
|
+
createRun(taskId: string, sessionId: string, owner: string | null, instanceId: string, meta?: NewRunMeta): Promise<CreateRunOutcome>;
|
|
40
44
|
/** Request cancel of a RUNNING run (durable, cross-replica). Sets the flag the owning instance's heartbeat
|
|
41
45
|
* tick polls; a same-replica caller additionally aborts the in-flight controller for instant effect. Only
|
|
42
46
|
* flags a `running` row (terminal/suspended → 0 affected = no-op). Returns whether a running row was flagged.
|
|
@@ -134,6 +138,16 @@ export declare class SqlRunStore {
|
|
|
134
138
|
* (今天没有消费点,所以没人发现)。列表收成一个常量 = 这类漏选从此不可能发生。 */
|
|
135
139
|
private rowToRun;
|
|
136
140
|
getRun(taskId: string): Promise<RunRecord | undefined>;
|
|
141
|
+
/** The row a submission's keys name — `idem_key` first, then `task_id` — read on the caller's (already rolled-back) connection. */
|
|
142
|
+
private observeSubmittedRow;
|
|
143
|
+
/** The run a submission's `Idempotency-Key` names (`idem_key` = the scoped key, `idemKeyOf(raw, caller, "run")`), if any.
|
|
144
|
+
* `uq_task_run_idem` makes it at most one row. */
|
|
145
|
+
getRunByIdem(idemKey: string): Promise<RunRecord | undefined>;
|
|
146
|
+
/** Undo THIS submission's key binding (`idem_key` → NULL) — called only where the submit surface refuses the submission
|
|
147
|
+
* before any work and terminalizes the row it just claimed (the sync leg's zero-work refusal, a device-placement reject):
|
|
148
|
+
* the error answer invites a retry, and a retry with the same key must run, not replay the refusal. The row stays (it
|
|
149
|
+
* is the refusal's record); only the key is freed. */
|
|
150
|
+
releaseIdemKey(taskId: string): Promise<void>;
|
|
137
151
|
/** Task Trace list (S1): runs newest-first, keyset-paginated on (created_at, task_id). Optional status +
|
|
138
152
|
* jobId filters (jobId = the work-view group, served by idx_job). Caller passes `limit+1` to detect a next
|
|
139
153
|
* page. Cursor fields are bound as params (no SQL injection).
|
|
@@ -74,40 +74,48 @@ export class SqlRunStore {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
async createRun(taskId, sessionId, owner, instanceId, meta = {}) {
|
|
77
|
+
const idemKey = meta.idemKey ?? null;
|
|
77
78
|
const conn = await this.db.connect();
|
|
79
|
+
let stage = "claim";
|
|
78
80
|
try {
|
|
79
81
|
await conn.begin();
|
|
80
82
|
const now = new Date();
|
|
81
83
|
try {
|
|
82
84
|
await conn.query(this.q("INSERT INTO task_active (session_id, task_id, since) VALUES (?,?,?)", "INSERT INTO task_active (session_id, task_id, since) VALUES ($1,$2,$3)"), [sessionId, taskId, now]);
|
|
85
|
+
stage = "row";
|
|
86
|
+
await conn.query(this.q("INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, idem_key, created_at, updated_at) " +
|
|
87
|
+
"VALUES (?,?,?,'running',?,?,?,?,?,?,?)", "INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, idem_key, created_at, updated_at) " +
|
|
88
|
+
"VALUES ($1,$2,$3,'running',$4,$5,$6,$7,$8,$9,$10)"), [
|
|
89
|
+
taskId,
|
|
90
|
+
sessionId,
|
|
91
|
+
owner,
|
|
92
|
+
instanceId,
|
|
93
|
+
meta.jobId ?? null,
|
|
94
|
+
meta.source ?? null,
|
|
95
|
+
this.db.dialect === "tidb"
|
|
96
|
+
? meta.objectivePreview ?? null
|
|
97
|
+
: meta.objectivePreview == null
|
|
98
|
+
? null
|
|
99
|
+
: pgSanitizeText(meta.objectivePreview),
|
|
100
|
+
idemKey,
|
|
101
|
+
now,
|
|
102
|
+
now,
|
|
103
|
+
]);
|
|
104
|
+
await conn.commit();
|
|
105
|
+
return { ok: true };
|
|
83
106
|
}
|
|
84
107
|
catch (err) {
|
|
85
|
-
if (this.isDupKey(err))
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
if (!this.isDupKey(err))
|
|
109
|
+
throw err;
|
|
110
|
+
await conn.rollback();
|
|
111
|
+
const existing = await this.observeSubmittedRow(conn, taskId, idemKey);
|
|
112
|
+
if (existing)
|
|
113
|
+
return { ok: false, existing };
|
|
114
|
+
if (stage === "row")
|
|
115
|
+
throw err;
|
|
116
|
+
const { rows: active } = await conn.query(this.q("SELECT task_id FROM task_active WHERE session_id = ?", "SELECT task_id FROM task_active WHERE session_id = $1"), [sessionId]);
|
|
117
|
+
return { ok: false, activeTaskId: String(active[0]?.task_id ?? "") };
|
|
91
118
|
}
|
|
92
|
-
await conn.query(this.q("INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, created_at, updated_at) " +
|
|
93
|
-
"VALUES (?,?,?,'running',?,?,?,?,?,?)", "INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, created_at, updated_at) " +
|
|
94
|
-
"VALUES ($1,$2,$3,'running',$4,$5,$6,$7,$8,$9)"), [
|
|
95
|
-
taskId,
|
|
96
|
-
sessionId,
|
|
97
|
-
owner,
|
|
98
|
-
instanceId,
|
|
99
|
-
meta.jobId ?? null,
|
|
100
|
-
meta.source ?? null,
|
|
101
|
-
this.db.dialect === "tidb"
|
|
102
|
-
? meta.objectivePreview ?? null
|
|
103
|
-
: meta.objectivePreview == null
|
|
104
|
-
? null
|
|
105
|
-
: pgSanitizeText(meta.objectivePreview),
|
|
106
|
-
now,
|
|
107
|
-
now,
|
|
108
|
-
]);
|
|
109
|
-
await conn.commit();
|
|
110
|
-
return { ok: true };
|
|
111
119
|
}
|
|
112
120
|
catch (err) {
|
|
113
121
|
try {
|
|
@@ -228,6 +236,22 @@ export class SqlRunStore {
|
|
|
228
236
|
const { rows } = await this.db.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE task_id = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE task_id = $1`), [taskId]);
|
|
229
237
|
return rows[0] ? this.rowToRun(rows[0]) : undefined;
|
|
230
238
|
}
|
|
239
|
+
async observeSubmittedRow(conn, taskId, idemKey) {
|
|
240
|
+
if (idemKey !== null) {
|
|
241
|
+
const { rows } = await conn.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE idem_key = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE idem_key = $1`), [idemKey]);
|
|
242
|
+
if (rows[0])
|
|
243
|
+
return this.rowToRun(rows[0]);
|
|
244
|
+
}
|
|
245
|
+
const { rows } = await conn.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE task_id = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE task_id = $1`), [taskId]);
|
|
246
|
+
return rows[0] ? this.rowToRun(rows[0]) : undefined;
|
|
247
|
+
}
|
|
248
|
+
async getRunByIdem(idemKey) {
|
|
249
|
+
const { rows } = await this.db.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE idem_key = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE idem_key = $1`), [idemKey]);
|
|
250
|
+
return rows[0] ? this.rowToRun(rows[0]) : undefined;
|
|
251
|
+
}
|
|
252
|
+
async releaseIdemKey(taskId) {
|
|
253
|
+
await this.db.query(this.q("UPDATE task_run SET idem_key = NULL WHERE task_id = ?", "UPDATE task_run SET idem_key = NULL WHERE task_id = $1"), [taskId]);
|
|
254
|
+
}
|
|
231
255
|
async listRuns(opts) {
|
|
232
256
|
if (opts.cursor && Number.isNaN(Date.parse(opts.cursor.createdAt)))
|
|
233
257
|
return [];
|
|
@@ -360,6 +384,7 @@ export class SqlRunStore {
|
|
|
360
384
|
lastRunId: r.task_id ?? null,
|
|
361
385
|
...(typeof lastTaskRunId === "string" && lastTaskRunId !== "" ? { lastTaskRunId } : {}),
|
|
362
386
|
title: null,
|
|
387
|
+
pinned: false,
|
|
363
388
|
};
|
|
364
389
|
}
|
|
365
390
|
async deleteBySession(sessionId, expectedSessionOwner) {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* `recordFailOpen` 在这条腿上**不适用** —— 它是 F 类(装饰/缓存)的登记口,不是 P 类保护臂的逃生门。
|
|
33
33
|
*/
|
|
34
34
|
import { type SchemaDialect } from "./schema-ensure-legs.js";
|
|
35
|
+
import { type SchemaProbeRead } from "./schema-live-columns.js";
|
|
35
36
|
/** 一列:名字 + 它在 `CREATE TABLE` 里的**原样定义文本**(生成 ADD COLUMN 指路用,不另手写)。 */
|
|
36
37
|
export interface ParsedColumn {
|
|
37
38
|
readonly name: string;
|
|
@@ -62,19 +63,6 @@ export interface TableMismatch {
|
|
|
62
63
|
/** 库有、代码没有(库比代码**新** —— 滚动窗里的旧副本)。 */
|
|
63
64
|
readonly ghost: readonly string[];
|
|
64
65
|
}
|
|
65
|
-
/** 探针读口:一条 SQL → 行数组(两方言由调用方各自绑)。 */
|
|
66
|
-
export type SchemaProbeRead = (sql: string) => Promise<Array<Record<string, unknown>>>;
|
|
67
|
-
/**
|
|
68
|
-
* 库里实际的列集(表名 → 列名集合)。
|
|
69
|
-
*
|
|
70
|
-
* 🔴 **PG 侧读的是「查询真正会解析到的那个关系」,不是 `current_schema()`**(codex 对抗复审本轮 [medium]
|
|
71
|
-
* 采纳):店里的 SQL 全是**不限定 schema** 的裸表名,它按 `search_path` **逐个** schema 解析,命中第一个;
|
|
72
|
-
* 而 `current_schema()` 只是路径的第一段。两者不等的部署是真的存在(角色的 `search_path` 配了多段),那时
|
|
73
|
-
* 只读第一段会让探针对「表其实住在路径后段、而且缺列」这一形**完全沉默**。⇒ 按 `current_schemas(false)`
|
|
74
|
-
* 的**顺序**取每张表的第一次命中 —— 探针于是与执行器看同一张表。MySQL 协议没有这个概念(库 = 连接上的
|
|
75
|
-
* `DATABASE()`),那条臂一个字不变。
|
|
76
|
-
*/
|
|
77
|
-
export declare function readLiveColumns(read: SchemaProbeRead, dialect: SchemaDialect): Promise<Map<string, Set<string>>>;
|
|
78
66
|
/** 对账(纯函数:期望 × 实况 → 不符表清单)。 */
|
|
79
67
|
export declare function diffSchema(expected: Map<string, ParsedTable>, live: Map<string, Set<string>>): TableMismatch[];
|
|
80
68
|
/** 拒启文案(表名 + 缺/多列 + 两条路;ADD COLUMN 句由解析结果**生成**)。 */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { recordEnsureStatements } from "./schema-ensure-legs.js";
|
|
2
|
+
import { readLiveColumns } from "./schema-live-columns.js";
|
|
2
3
|
const QUOTES = new Set(["'", '"', "`"]);
|
|
3
4
|
function stripLineComments(sql) {
|
|
4
5
|
let out = "";
|
|
@@ -104,47 +105,6 @@ export async function expectedTableShapes(dialect) {
|
|
|
104
105
|
return shapes;
|
|
105
106
|
}
|
|
106
107
|
export const SCHEMA_MISMATCH_ERROR_CODE = "config.schema_mismatch";
|
|
107
|
-
export async function readLiveColumns(read, dialect) {
|
|
108
|
-
const live = new Map();
|
|
109
|
-
if (dialect !== "pg") {
|
|
110
|
-
for (const row of await read("SELECT table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = DATABASE()")) {
|
|
111
|
-
addColumn(live, row["t"], row["c"], "fold");
|
|
112
|
-
}
|
|
113
|
-
return live;
|
|
114
|
-
}
|
|
115
|
-
const order = new Map();
|
|
116
|
-
for (const row of await read("SELECT s AS s, ord AS ord FROM unnest(current_schemas(false)) WITH ORDINALITY AS t(s, ord)")) {
|
|
117
|
-
const s = String(row["s"] ?? "");
|
|
118
|
-
if (s !== "")
|
|
119
|
-
order.set(s, Number(row["ord"]));
|
|
120
|
-
}
|
|
121
|
-
const winner = new Map();
|
|
122
|
-
for (const row of await read("SELECT table_schema AS s, table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = ANY (current_schemas(false))")) {
|
|
123
|
-
const t = String(row["t"] ?? "");
|
|
124
|
-
const rank = order.get(String(row["s"] ?? "")) ?? Number.MAX_SAFE_INTEGER;
|
|
125
|
-
const held = winner.get(t);
|
|
126
|
-
if (held !== undefined && rank > held)
|
|
127
|
-
continue;
|
|
128
|
-
if (held === undefined || rank < held) {
|
|
129
|
-
winner.set(t, rank);
|
|
130
|
-
live.delete(t);
|
|
131
|
-
}
|
|
132
|
-
addColumn(live, t, row["c"], "verbatim");
|
|
133
|
-
}
|
|
134
|
-
return live;
|
|
135
|
-
}
|
|
136
|
-
function addColumn(live, table, column, mode) {
|
|
137
|
-
const t = mode === "fold" ? String(table ?? "").toLowerCase() : String(table ?? "");
|
|
138
|
-
const c = mode === "fold" ? String(column ?? "").toLowerCase() : String(column ?? "");
|
|
139
|
-
if (t === "" || c === "")
|
|
140
|
-
return;
|
|
141
|
-
let set = live.get(t);
|
|
142
|
-
if (set === undefined) {
|
|
143
|
-
set = new Set();
|
|
144
|
-
live.set(t, set);
|
|
145
|
-
}
|
|
146
|
-
set.add(c);
|
|
147
|
-
}
|
|
148
108
|
export function diffSchema(expected, live) {
|
|
149
109
|
const out = [];
|
|
150
110
|
for (const [table, shape] of expected) {
|
|
@@ -3,6 +3,7 @@ export const CENTRAL_SCHEMA_INDEXES = [
|
|
|
3
3
|
{ table: "task_run", name: "idx_task_run_owner", columns: ["owner"] },
|
|
4
4
|
{ table: "task_run", name: "idx_task_run_created", columns: ["created_at"] },
|
|
5
5
|
{ table: "task_run", name: "idx_task_run_job", columns: ["job_id"] },
|
|
6
|
+
{ table: "task_run", name: "uq_task_run_idem", columns: ["idem_key"], unique: true },
|
|
6
7
|
{ table: "task_active", name: "idx_task_active_task", columns: ["task_id"] },
|
|
7
8
|
{ table: "checkpoint", name: "idx_checkpoint_status_deadline", columns: ["status", "deadline"] },
|
|
8
9
|
{ table: "checkpoint", name: "idx_checkpoint_session_status", columns: ["session_id", "status"] },
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 库里**实际**的列集 —— 唯一读者(S-547 列册探针 `schema-consistency.ts` 的对账读它;`ensure-index.ts` 的索引补建在失败之后判「是不是这张表
|
|
3
|
+
* 缺这条索引的列」也读它)。叶子模块:不值依赖任何模块(列册探针那边经录制驱动 / 建表腿值依赖 `ensure-index.ts`,读者若住在那边,
|
|
4
|
+
* `ensure-index.ts` 读它就是一个运行时环)。方言词是 `ensure-index.ts` 的闭集(类型边,擦除)。
|
|
5
|
+
*/
|
|
6
|
+
import type { SqlDialect } from "./ensure-index.js";
|
|
7
|
+
/** 探针读口:一条 SQL → 行数组(两方言由调用方各自绑)。 */
|
|
8
|
+
export type SchemaProbeRead = (sql: string) => Promise<Array<Record<string, unknown>>>;
|
|
9
|
+
/**
|
|
10
|
+
* 库里实际的列集(表名 → 列名集合)。
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **PG 侧读的是「查询真正会解析到的那个关系」,不是 `current_schema()`**(codex 对抗复审本轮 [medium]
|
|
13
|
+
* 采纳):店里的 SQL 全是**不限定 schema** 的裸表名,它按 `search_path` **逐个** schema 解析,命中第一个;
|
|
14
|
+
* 而 `current_schema()` 只是路径的第一段。两者不等的部署是真的存在(角色的 `search_path` 配了多段),那时
|
|
15
|
+
* 只读第一段会让探针对「表其实住在路径后段、而且缺列」这一形**完全沉默**。⇒ 按 `current_schemas(false)`
|
|
16
|
+
* 的**顺序**取每张表的第一次命中 —— 探针于是与执行器看同一张表。MySQL 协议没有这个概念(库 = 连接上的
|
|
17
|
+
* `DATABASE()`),那条臂一个字不变。
|
|
18
|
+
*/
|
|
19
|
+
export declare function readLiveColumns(read: SchemaProbeRead, dialect: SqlDialect): Promise<Map<string, Set<string>>>;
|
|
20
|
+
//# sourceMappingURL=schema-live-columns.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export async function readLiveColumns(read, dialect) {
|
|
2
|
+
const live = new Map();
|
|
3
|
+
if (dialect !== "pg") {
|
|
4
|
+
for (const row of await read("SELECT table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = DATABASE()")) {
|
|
5
|
+
addColumn(live, row["t"], row["c"], "fold");
|
|
6
|
+
}
|
|
7
|
+
return live;
|
|
8
|
+
}
|
|
9
|
+
const order = new Map();
|
|
10
|
+
for (const row of await read("SELECT s AS s, ord AS ord FROM unnest(current_schemas(false)) WITH ORDINALITY AS t(s, ord)")) {
|
|
11
|
+
const s = String(row["s"] ?? "");
|
|
12
|
+
if (s !== "")
|
|
13
|
+
order.set(s, Number(row["ord"]));
|
|
14
|
+
}
|
|
15
|
+
const winner = new Map();
|
|
16
|
+
for (const row of await read("SELECT table_schema AS s, table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = ANY (current_schemas(false))")) {
|
|
17
|
+
const t = String(row["t"] ?? "");
|
|
18
|
+
const rank = order.get(String(row["s"] ?? "")) ?? Number.MAX_SAFE_INTEGER;
|
|
19
|
+
const held = winner.get(t);
|
|
20
|
+
if (held !== undefined && rank > held)
|
|
21
|
+
continue;
|
|
22
|
+
if (held === undefined || rank < held) {
|
|
23
|
+
winner.set(t, rank);
|
|
24
|
+
live.delete(t);
|
|
25
|
+
}
|
|
26
|
+
addColumn(live, t, row["c"], "verbatim");
|
|
27
|
+
}
|
|
28
|
+
return live;
|
|
29
|
+
}
|
|
30
|
+
function addColumn(live, table, column, mode) {
|
|
31
|
+
const t = mode === "fold" ? String(table ?? "").toLowerCase() : String(table ?? "");
|
|
32
|
+
const c = mode === "fold" ? String(column ?? "").toLowerCase() : String(column ?? "");
|
|
33
|
+
if (t === "" || c === "")
|
|
34
|
+
return;
|
|
35
|
+
let set = live.get(t);
|
|
36
|
+
if (set === undefined) {
|
|
37
|
+
set = new Set();
|
|
38
|
+
live.set(t, set);
|
|
39
|
+
}
|
|
40
|
+
set.add(c);
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=schema-live-columns.js.map
|
|
@@ -221,6 +221,40 @@ export interface RunRecord {
|
|
|
221
221
|
createdAt: string;
|
|
222
222
|
updatedAt: string;
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* `RunStore.createRun` 的行元数据(写进 `task_run` 的附加列)。
|
|
226
|
+
*
|
|
227
|
+
* `idemKey` = 这次提交的幂等键(唯一铸法 `idemKeyOf(raw, caller, "run")`,`http/idempotency.ts`;sha256 hex 定宽 64)。
|
|
228
|
+
* 缺席 / `null` = 调用方没带 `Idempotency-Key` ⇒ 列 NULL,不参与去重(UNIQUE 不约束 NULL,两方言同形)。
|
|
229
|
+
*/
|
|
230
|
+
export interface NewRunMeta {
|
|
231
|
+
jobId?: string | null;
|
|
232
|
+
source?: string | null;
|
|
233
|
+
objectivePreview?: string | null;
|
|
234
|
+
idemKey?: string | null;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* `RunStore.createRun` 的结局 —— 三形,一形一句话:
|
|
238
|
+
* · `{ ok: true }` —— 新行已建、会话 claim 已拿;
|
|
239
|
+
* · `{ ok: false, existing }` —— 这次提交的一把**幂等钥匙**已经指向一条行:`idem_key`(`Idempotency-Key`)或
|
|
240
|
+
* `task_id`(调用方自铸的 `body.taskId`)—— 两把钥匙是**同一条律**(资源行上的唯一键),先认 `idem_key` 再认 `task_id`。
|
|
241
|
+
* 这是「原子赢或观察」的**观察**臂:行原样交回,没建新行、没动会话;回执由提交面的唯一回执函数判。
|
|
242
|
+
* · `{ ok: false, activeTaskId }` —— 会话被**别的** run 占着(双发守卫),没建行。
|
|
243
|
+
* 另两位在每一形上恒 `undefined`(读 `created.existing` / `created.activeTaskId` 不用先窄化,判别仍是穷举的)。
|
|
244
|
+
*/
|
|
245
|
+
export type CreateRunOutcome = {
|
|
246
|
+
ok: true;
|
|
247
|
+
existing?: undefined;
|
|
248
|
+
activeTaskId?: undefined;
|
|
249
|
+
} | {
|
|
250
|
+
ok: false;
|
|
251
|
+
existing: RunRecord;
|
|
252
|
+
activeTaskId?: undefined;
|
|
253
|
+
} | {
|
|
254
|
+
ok: false;
|
|
255
|
+
existing?: undefined;
|
|
256
|
+
activeTaskId: string;
|
|
257
|
+
};
|
|
224
258
|
/**
|
|
225
259
|
* `RunRecord["status"]`(= `"running" | TaskStatus`)的**唯一分类表** —— 词表的属主 = 判据的属主,
|
|
226
260
|
* 与类型定义同住一个文件。四个类:
|
|
@@ -402,9 +436,13 @@ export interface SessionSummary {
|
|
|
402
436
|
* 零迁移路线 —— result 在终局与 park 时写、认领续跑即清空(S-173)⇒ 在跑 run 键诚实缺席);OMIT-when-absent,恒不铸 null。轴辨析与
|
|
403
437
|
* wire 半场见 `security.ts SessionListItem.lastTaskRunId`(同名同义)。 */
|
|
404
438
|
lastTaskRunId?: string;
|
|
405
|
-
/** The
|
|
406
|
-
* titled OR the lister face has no session_meta (the legacy
|
|
439
|
+
/** The session's title: the user's (`patchSessionMeta`, unconditional; `null` clears it back to automatic) or the auto-titler's
|
|
440
|
+
* (cheap-model one-liner, `setTitleIfNull` — fills NULL only). null = not (yet) titled OR the lister face has no session_meta (the legacy
|
|
441
|
+
* task_run aggregate / memory twins — honest degrade). Writer order: `src/session-row.ts`. */
|
|
407
442
|
title: string | null;
|
|
443
|
+
/** Pinned by the user (`patchSessionMeta`). Always present, `false` by default; the legacy task_run aggregate / memory
|
|
444
|
+
* twins answer `false` (no session_meta ⇒ nothing there can pin it). */
|
|
445
|
+
pinned: boolean;
|
|
408
446
|
}
|
|
409
447
|
/**
|
|
410
448
|
* S-1371 —— 会话**枚举面**读 run 账里「每会话的 run 投影」的窄读面:`runCount` / `lastStatus` / `lastRunId` / `lastTaskRunId` 四位,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `maxCostUsd` gate). `scope_key` is opaque (principal today; team/feature later — [ref] N3).
|
|
6
6
|
*/
|
|
7
7
|
import type { Pool } from "mysql2/promise";
|
|
8
|
-
import type
|
|
8
|
+
import { type QuotaDecision, type QuotaLimits, type QuotaTracker } from "../observability/cost-quota.js";
|
|
9
9
|
import { type CounterDegradeHook } from "./write-behind-counter.js";
|
|
10
10
|
export declare class TiDBCostQuota implements QuotaTracker {
|
|
11
11
|
private readonly counter;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decideQuota } from "../observability/cost-quota.js";
|
|
1
2
|
import { WriteBehindCounter } from "./write-behind-counter.js";
|
|
2
3
|
export class TiDBCostQuota {
|
|
3
4
|
counter;
|
|
@@ -22,11 +23,7 @@ export class TiDBCostQuota {
|
|
|
22
23
|
this.counter.stop();
|
|
23
24
|
}
|
|
24
25
|
check(scopeKey) {
|
|
25
|
-
|
|
26
|
-
return { allowed: true, usedMicroUsd: 0, limitMicroUsd: 0, retryAfterSec: 0 };
|
|
27
|
-
const used = this.counter.used(scopeKey);
|
|
28
|
-
const allowed = used < this.limitMicroUsd;
|
|
29
|
-
return { allowed, usedMicroUsd: used, limitMicroUsd: this.limitMicroUsd, retryAfterSec: allowed ? 0 : this.counter.retryAfterSec() };
|
|
26
|
+
return decideQuota(this.limitMicroUsd, this.counter.window(), (bucket) => this.counter.usedIn(scopeKey, bucket));
|
|
30
27
|
}
|
|
31
28
|
add(scopeKey, micro) {
|
|
32
29
|
if (this.limitMicroUsd <= 0)
|
|
@@ -7,6 +7,7 @@ import { TIDB_ADOPTION_LOG_STATEMENTS } from "./adoption-log-sql.js";
|
|
|
7
7
|
import { TIDB_PERMISSION_RULE_STATEMENTS } from "./permission-rule-store-sql.js";
|
|
8
8
|
import { TIDB_RETENTION_STATEMENTS } from "./retention-store-sql.js";
|
|
9
9
|
import { SESSION_META_INCARNATION_COLUMN } from "../session-incarnation.js";
|
|
10
|
+
import { SESSION_TITLE_MAX_CHARS } from "../session-row.js";
|
|
10
11
|
import { ensureIndexes } from "./ensure-index.js";
|
|
11
12
|
import { CENTRAL_SCHEMA_INDEXES } from "./schema-indexes.js";
|
|
12
13
|
import { SESSION_INDEXES } from "./pg-session-storage.js";
|
|
@@ -56,9 +57,13 @@ export const SCHEMA_STATEMENTS = [
|
|
|
56
57
|
updated_at DATETIME(3) NOT NULL,
|
|
57
58
|
leaf_id VARCHAR(64) NULL,
|
|
58
59
|
leaf_seq BIGINT NOT NULL DEFAULT 0,
|
|
59
|
-
-- title:
|
|
60
|
-
--
|
|
61
|
-
|
|
60
|
+
-- title: ONE column, two writers, one law (src/session-row.ts): the user's write (PATCH /v1/sessions/:id →
|
|
61
|
+
-- patchSessionMeta, unconditional; null = cleared back to the automatic title) and the auto-titler (cheap-model
|
|
62
|
+
-- one-liner on a submit that finds it NULL; setTitleIfNull, fills NULL only) — a user-set title is never overwritten,
|
|
63
|
+
-- a cleared one is filled again. Width = SESSION_TITLE_MAX_CHARS (the wire bound).
|
|
64
|
+
title VARCHAR(${SESSION_TITLE_MAX_CHARS}) NULL,
|
|
65
|
+
-- pinned: the user's pin (PATCH /v1/sessions/:id → patchSessionMeta); every list row carries it, false by default.
|
|
66
|
+
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
|
62
67
|
-- instruction_source(S-690):本会话**上一次 run** 选中的项目指令文件 = 换源通告(instructions.source_changed)
|
|
63
68
|
-- 的基线。NULL = 还没有任何 run 记过(首 run 只写不发);'' = 记过、那一次没有项目指令文件;其余 = 链成员相对路径
|
|
64
69
|
-- (CLAUDE.md / .claude/CLAUDE.md / AGENTS.md / .claude/AGENTS.md)。服务端元数据:不进模型上下文、不进 wire
|
|
@@ -160,9 +165,15 @@ export const SCHEMA_STATEMENTS = [
|
|
|
160
165
|
-- (markResuming) so a resumed leg is not re-preempted by a stale flag (the durable twin of core's
|
|
161
166
|
-- pre-aborted-signal strip).
|
|
162
167
|
preempt_requested TINYINT(1) NOT NULL DEFAULT 0,
|
|
168
|
+
-- idem_key: the submission's scoped Idempotency-Key (sha256 hex, fixed width 64; minted only by
|
|
169
|
+
-- http/idempotency.ts idemKeyOf, resource family run). The ONE enforcement point of submission idempotency for
|
|
170
|
+
-- runs: uq_task_run_idem makes createRun an atomic win-or-observe across replicas and restarts. NULL = the caller
|
|
171
|
+
-- sent no key (UNIQUE does not constrain NULL; same on both dialects).
|
|
172
|
+
idem_key VARCHAR(64) NULL,
|
|
163
173
|
created_at DATETIME(3) NOT NULL,
|
|
164
174
|
updated_at DATETIME(3) NOT NULL,
|
|
165
175
|
PRIMARY KEY (task_id),
|
|
176
|
+
UNIQUE KEY uq_task_run_idem (idem_key),
|
|
166
177
|
KEY idx_task_run_session_status (session_id, status),
|
|
167
178
|
KEY idx_task_run_owner (owner),
|
|
168
179
|
-- idx_job: WHERE job_id = ? (work-view grouping) would full-scan without it.
|
|
@@ -15,6 +15,7 @@ import type { Pool } from "mysql2/promise";
|
|
|
15
15
|
import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
|
|
16
16
|
import { type SessionFace } from "../session-incarnation.js";
|
|
17
17
|
import type { SessionSummary } from "./store-contracts.js";
|
|
18
|
+
import type { SessionRowPatch } from "../session-row.js";
|
|
18
19
|
import { type PlacementAcquireOpts } from "./session-placement.js";
|
|
19
20
|
import { type StagingHandle } from "../session-sync-kernel.js";
|
|
20
21
|
export declare class TiDBSessionStore implements SessionStore {
|
|
@@ -115,11 +116,30 @@ export declare class TiDBSessionStore implements SessionStore {
|
|
|
115
116
|
limit: number;
|
|
116
117
|
q?: string;
|
|
117
118
|
}): Promise<SessionSummary[]>;
|
|
119
|
+
/** ONE session's list row — {@link listSessions}' projection (same SELECT, same mapper) scoped to one id; `undefined` = not a
|
|
120
|
+
* row of the list face (no such session / placed). The run-preview subqueries are scoped to the same id (an index read on
|
|
121
|
+
* `task_run.session_id`, not the whole-ledger aggregate the list pays). */
|
|
122
|
+
sessionSummaryOf(sessionId: string): Promise<SessionSummary | undefined>;
|
|
123
|
+
/** The session row's writable bits — the ONLY writer of a user-set title / pin (writer order: `src/session-row.ts`):
|
|
124
|
+
* `title` unconditional (a string = the user's title — the auto-titler's {@link setTitleIfNull} fills NULL only, so it always
|
|
125
|
+
* wins; `null` = cleared back to the automatic title, which the auto-titler fills again on a later submit), `pinned` as given.
|
|
126
|
+
* Only a row of the list face takes it (`placement_kind IS NULL`), and only the occupant the caller verified (`incarnation = ?`
|
|
127
|
+
* from `face` — a session deleted and re-registered by anyone after the caller's gate is another occupant: zero rows updated).
|
|
128
|
+
* The read-back carries the same condition, so the answer is that occupant's list row or `undefined` (the decision does not
|
|
129
|
+
* read `affectedRows`: an UPDATE that wrote identical values and one that matched nothing differ there by driver flags, the
|
|
130
|
+
* conditioned read-back does not). `updated_at` is NOT touched (a rename is not activity — the list's newest-first order stays put). */
|
|
131
|
+
patchSessionMeta(sessionId: string, patch: SessionRowPatch, face: SessionFace): Promise<SessionSummary | undefined>;
|
|
132
|
+
/**
|
|
133
|
+
* The list projection's ONE SELECT + row mapper ({@link listSessions} and {@link sessionSummaryOf} both read through it).
|
|
134
|
+
* `runSessionId` scopes the two run-preview subqueries to one session (the single-row read); the list leaves them whole.
|
|
135
|
+
* Placeholder order follows the text: subquery params, then `where` params, then the LIMIT.
|
|
136
|
+
*/
|
|
137
|
+
private selectSummaries;
|
|
118
138
|
/** Cheap pre-LLM probe: "none" = session_meta row not yet REGISTERED (createRun fires
|
|
119
139
|
* before the Runner's session acquire — the titler retries), "untitled" / "titled" as read. */
|
|
120
140
|
probeTitle(sessionId: string): Promise<"none" | "untitled" | "titled">;
|
|
121
|
-
/**
|
|
122
|
-
*
|
|
141
|
+
/** The auto-titler's write: lands ONLY when title IS NULL (a concurrent/prior title — the user's included — wins;
|
|
142
|
+
* writer order: `src/session-row.ts`). Returns whether this call set it. */
|
|
123
143
|
setTitleIfNull(sessionId: string, title: string): Promise<boolean>;
|
|
124
144
|
/**
|
|
125
145
|
* S-690 —— 换源通告的基线读(一行 PK SELECT;列的三态读法见 `tidb-pool.ts` 的列注)。行不在 ⇒ 读作「没记过」
|
|
@@ -153,18 +153,44 @@ export class TiDBSessionStore {
|
|
|
153
153
|
where.push("(sm.updated_at < ? OR (sm.updated_at = ? AND sm.session_id < ?))");
|
|
154
154
|
params.push(new Date(opts.cursor.lastActivityAt), new Date(opts.cursor.lastActivityAt), opts.cursor.sessionId);
|
|
155
155
|
}
|
|
156
|
-
|
|
156
|
+
return this.selectSummaries({ where, params, limit: opts.limit });
|
|
157
|
+
}
|
|
158
|
+
async sessionSummaryOf(sessionId) {
|
|
159
|
+
const rows = await this.selectSummaries({ where: ["sm.placement_kind IS NULL", "sm.session_id = ?"], params: [sessionId], limit: 1, runSessionId: sessionId });
|
|
160
|
+
return rows[0];
|
|
161
|
+
}
|
|
162
|
+
async patchSessionMeta(sessionId, patch, face) {
|
|
163
|
+
const sets = [];
|
|
164
|
+
const params = [];
|
|
165
|
+
if (patch.title !== undefined) {
|
|
166
|
+
sets.push("title = ?");
|
|
167
|
+
params.push(patch.title);
|
|
168
|
+
}
|
|
169
|
+
if (patch.pinned !== undefined) {
|
|
170
|
+
sets.push("pinned = ?");
|
|
171
|
+
params.push(patch.pinned);
|
|
172
|
+
}
|
|
173
|
+
if (sets.length > 0) {
|
|
174
|
+
params.push(sessionId, face.incarnation);
|
|
175
|
+
await this.pool.query(`UPDATE session_meta SET ${sets.join(", ")} WHERE session_id = ? AND incarnation = ? AND placement_kind IS NULL`, params);
|
|
176
|
+
}
|
|
177
|
+
const rows = await this.selectSummaries({ where: ["sm.placement_kind IS NULL", "sm.session_id = ?", "sm.incarnation = ?"], params: [sessionId, face.incarnation], limit: 1, runSessionId: sessionId });
|
|
178
|
+
return rows[0];
|
|
179
|
+
}
|
|
180
|
+
async selectSummaries(q) {
|
|
181
|
+
const runWhere = q.runSessionId !== undefined ? " WHERE session_id = ?" : "";
|
|
182
|
+
const runParams = q.runSessionId !== undefined ? [q.runSessionId, q.runSessionId] : [];
|
|
183
|
+
const sql = "SELECT sm.session_id, sm.owner, sm.updated_at AS last_activity, sm.created_at AS first_activity, sm.title, sm.pinned, " +
|
|
157
184
|
" COALESCE(r.run_count, 0) AS run_count, latest.objective_preview, latest.status, latest.task_id AS last_run_id, latest.last_task_run_id " +
|
|
158
185
|
"FROM session_meta sm " +
|
|
159
|
-
"LEFT JOIN (SELECT session_id, COUNT(*) AS run_count FROM task_run GROUP BY session_id) r ON r.session_id = sm.session_id " +
|
|
186
|
+
"LEFT JOIN (SELECT session_id, COUNT(*) AS run_count FROM task_run" + runWhere + " GROUP BY session_id) r ON r.session_id = sm.session_id " +
|
|
160
187
|
"LEFT JOIN (SELECT session_id, objective_preview, status, task_id, last_task_run_id FROM (" +
|
|
161
188
|
" SELECT session_id, objective_preview, status, task_id, JSON_UNQUOTE(JSON_EXTRACT(result, '$.runId')) AS last_task_run_id, " +
|
|
162
|
-
" ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn FROM task_run" +
|
|
189
|
+
" ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn FROM task_run" + runWhere +
|
|
163
190
|
" ) lr WHERE rn = 1) latest ON latest.session_id = sm.session_id " +
|
|
164
|
-
(where.length ? "WHERE " + where.join(" AND ") + " " : "") +
|
|
191
|
+
(q.where.length ? "WHERE " + q.where.join(" AND ") + " " : "") +
|
|
165
192
|
"ORDER BY sm.updated_at DESC, sm.session_id DESC LIMIT ?";
|
|
166
|
-
|
|
167
|
-
const [rows] = await this.pool.query(sql, params);
|
|
193
|
+
const [rows] = await this.pool.query(sql, [...runParams, ...q.params, q.limit]);
|
|
168
194
|
return rows.map((r) => {
|
|
169
195
|
const runId = r.last_task_run_id;
|
|
170
196
|
return {
|
|
@@ -178,6 +204,7 @@ export class TiDBSessionStore {
|
|
|
178
204
|
lastRunId: r.last_run_id ?? null,
|
|
179
205
|
...(typeof runId === "string" && runId !== "" ? { lastTaskRunId: runId } : {}),
|
|
180
206
|
title: r.title ?? null,
|
|
207
|
+
pinned: Number(r.pinned) === 1,
|
|
181
208
|
};
|
|
182
209
|
});
|
|
183
210
|
}
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* up to (replicas × per-flush-interval spend/requests) at the boundary before peers' totals propagate.
|
|
17
17
|
* Right for cost/fairness ceilings (and backstopped, for cost, by the hard per-task `maxCostUsd` gate).
|
|
18
18
|
* A HARD compliance ceiling would instead need a linearizable CAS/atomic count, not write-behind — same
|
|
19
|
-
* trade as the cross-replica circuit breaker. Windows are aligned fixed buckets
|
|
20
|
-
*
|
|
19
|
+
* trade as the cross-replica circuit breaker. Windows are clock-aligned fixed buckets — the ONE anchor
|
|
20
|
+
* {@link alignedWindow} (`observability/cost-quota.ts`), shared with the in-memory cost quota — the standard
|
|
21
|
+
* distributed choice (a rolling per-key window is the hard part to coordinate cross-replica).
|
|
21
22
|
*
|
|
22
23
|
* ── Dialect deltas, kept EXPLICIT (the four the PG twins' file headers used to list) ──────────────────
|
|
23
24
|
* - placeholders `?` vs `$1..$n`
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
*/
|
|
33
34
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
34
35
|
import type { Pool as PgPool } from "pg";
|
|
36
|
+
import { type AlignedWindow } from "../observability/cost-quota.js";
|
|
35
37
|
export interface CounterTable {
|
|
36
38
|
/** Table name; must have columns (<keyCol>, window_bucket BIGINT, <valCol>, updated_at). */
|
|
37
39
|
table: string;
|
|
@@ -80,12 +82,17 @@ export declare class SqlWriteBehindCounter {
|
|
|
80
82
|
/** S9: track the consecutive-failure streak per kind and notify on every failure + on the recovery edge
|
|
81
83
|
* (streak>0 → 0). Steady healthy state stays silent. The hook must never break the counter. */
|
|
82
84
|
private bump;
|
|
85
|
+
/** One clock read → the current aligned window (bucket + end), via the ONE anchor {@link alignedWindow}. */
|
|
86
|
+
window(): AlignedWindow;
|
|
83
87
|
bucket(): number;
|
|
84
88
|
private rk;
|
|
85
|
-
/** Cached fleet total (peers, from the last refresh) + this replica's unflushed delta,
|
|
89
|
+
/** Cached fleet total (peers, from the last refresh) + this replica's unflushed delta, in `bucket`
|
|
90
|
+
* (the caller's own clock read — a decision reads its spend and its window end from ONE `now`). */
|
|
91
|
+
usedIn(key: string, bucket: number): number;
|
|
92
|
+
/** {@link usedIn} for the current window. */
|
|
86
93
|
used(key: string): number;
|
|
87
94
|
addLocal(key: string, n: number): void;
|
|
88
|
-
/** Seconds until the current aligned window rolls over. */
|
|
95
|
+
/** Seconds until the current aligned window rolls over (the ONE formula {@link retryAfterSecUntil}). */
|
|
89
96
|
retryAfterSec(): number;
|
|
90
97
|
/** Atomically push pending deltas, then refresh the current window's fleet totals + reap old buckets.
|
|
91
98
|
* Reentrancy-guarded: two overlapping flushes would each apply `+delta` from the same snapshot and
|
|
@@ -111,7 +118,7 @@ export declare class SqlWriteBehindCounter {
|
|
|
111
118
|
/**
|
|
112
119
|
* [ref] 批1:窗长换代(`costQuotaWindowSec` 热更新)。
|
|
113
120
|
*
|
|
114
|
-
* 🔴 如实语义:窗长是**桶键的一部分**(
|
|
121
|
+
* 🔴 如实语义:窗长是**桶键的一部分**(桶号由 {@link alignedWindow} 算),不是行里的一个可改字段。换窗长 ⇒ 当代
|
|
115
122
|
* 桶重算 ⇒ 本副本对「当前窗已用多少」的读数从新桶起算(旧桶的行还在库里,按它自己的窗长归属)。
|
|
116
123
|
* 未 flush 的本地增量仍带着**它自己的**桶键(rowKey 里已含),所以它们仍落回原来那个桶,不会被搬家。
|
|
117
124
|
* 这与限额值的换代不同(那个是纯比较参数,窗内计数一个不动),因此单独成注:运营改窗长要知道自己在
|