@sema-agent/server 7.93.4 → 7.93.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bake-runner/main.d.ts +28 -1
- package/dist/bake-runner/main.js +122 -38
- package/dist/bake-runner/runner.d.ts +85 -7
- package/dist/bake-runner/runner.js +116 -27
- package/dist/boot/exit-boundary.d.ts +22 -0
- package/dist/boot/exit-boundary.js +73 -0
- package/dist/boot/shutdown.d.ts +2 -2
- package/dist/boot/shutdown.js +5 -62
- package/dist/hooks/hook-runner.js +5 -1
- package/dist/observability/fail-open.d.ts +16 -0
- package/dist/observability/fail-open.js +16 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
- package/dist/plugins/approval-ask-store-sql.js +36 -19
- package/dist/plugins/background-agent-store-sql.js +36 -16
- package/dist/plugins/checkpoint-store-sql.d.ts +5 -1
- package/dist/plugins/checkpoint-store-sql.js +111 -66
- package/dist/plugins/device-store-sql.d.ts +10 -0
- package/dist/plugins/device-store-sql.js +12 -6
- package/dist/plugins/host-platform.d.ts +70 -6
- package/dist/plugins/host-platform.js +14 -3
- package/dist/plugins/leader-run-store-sql.js +16 -6
- package/dist/plugins/one-time-migrations.d.ts +3 -0
- package/dist/plugins/one-time-migrations.js +16 -4
- package/dist/plugins/permission-rule-store-sql.d.ts +1 -0
- package/dist/plugins/permission-rule-store-sql.js +37 -28
- package/dist/plugins/remote-env-host.js +9 -21
- package/dist/plugins/run-store-sql.d.ts +1 -1
- package/dist/plugins/run-store-sql.js +41 -10
- package/dist/plugins/session-policy-store-sql.d.ts +24 -0
- package/dist/plugins/session-policy-store-sql.js +17 -18
- package/dist/plugins/sql-json-column.d.ts +177 -0
- package/dist/plugins/sql-json-column.js +74 -0
- package/dist/plugins/task-list-store-sql.d.ts +13 -1
- package/dist/plugins/task-list-store-sql.js +23 -10
- package/dist/plugins/workflow-run-store-sql.js +14 -4
- package/dist/run-local.d.ts +15 -0
- package/dist/run-local.js +32 -0
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
|
|
|
4
4
|
import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
|
|
5
5
|
import { redactDeep, redactSecrets } from "../trace/redact.js";
|
|
6
6
|
import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
|
|
7
|
-
import {
|
|
7
|
+
import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
|
|
8
8
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
9
9
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
10
10
|
import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
|
|
@@ -113,22 +113,61 @@ export function boundedRuleOffers(raw, opts = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
return out.length > 0 ? out : undefined;
|
|
115
115
|
}
|
|
116
|
+
const readCheckpointBlobBody = createObjectColumnReader();
|
|
117
|
+
const readResumeOutcomeBody = createObjectColumnReader();
|
|
118
|
+
const readGateOutcomeBody = createObjectColumnReader();
|
|
119
|
+
const readRiskDescriptorBody = createObjectColumnReader();
|
|
120
|
+
function readJsonArrayBody(body) {
|
|
121
|
+
return Array.isArray(body) ? { value: body } : { problem: "expected a JSON array" };
|
|
122
|
+
}
|
|
123
|
+
function decodeCheckpointColumn(cell, where) {
|
|
124
|
+
return decodeJsonColumn(cell, readCheckpointBlobBody, `checkpoint.checkpoint ${where}`);
|
|
125
|
+
}
|
|
126
|
+
function decodeOutcomeColumn(cell, where) {
|
|
127
|
+
return decodeNullableJsonColumn(cell, readResumeOutcomeBody, `checkpoint.outcome ${where}`);
|
|
128
|
+
}
|
|
129
|
+
function decodeExecutionOutcomeColumn(cell, where) {
|
|
130
|
+
return decodeNullableJsonColumn(cell, readGateOutcomeBody, `checkpoint.execution_outcome ${where}`);
|
|
131
|
+
}
|
|
132
|
+
function decodeToolInputColumn(cell) {
|
|
133
|
+
return decodeNullableJsonColumn(cell, readNonNullJsonValueColumn, "checkpoint.tool_input (operator queue row)");
|
|
134
|
+
}
|
|
135
|
+
function decodeCtxColumn(cell, where) {
|
|
136
|
+
const body = decodeJsonColumn(cell, readAnyJsonValueColumn, `checkpoint_ctx.ctx ${where}`);
|
|
137
|
+
return body;
|
|
138
|
+
}
|
|
139
|
+
function refusing(what, read) {
|
|
140
|
+
try {
|
|
141
|
+
return read();
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
throw foldJsonColumn(e, (m) => new Error(`SqlCheckpointStore: ${m} — refusing rather than serve ${what}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
116
147
|
function readRuleOffersCell(cell) {
|
|
148
|
+
if (cell === null)
|
|
149
|
+
return undefined;
|
|
117
150
|
let parsed;
|
|
118
151
|
try {
|
|
119
|
-
parsed =
|
|
152
|
+
parsed = decodeJsonColumn(cell, readJsonArrayBody, "checkpoint.rule_suggestions (operator queue row)");
|
|
120
153
|
}
|
|
121
|
-
catch {
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (!isJsonColumnBodyProblem(e))
|
|
156
|
+
throw e;
|
|
122
157
|
recordFailOpen("server.approvals.rule-suggestions-cell-unreadable");
|
|
123
158
|
return undefined;
|
|
124
159
|
}
|
|
125
160
|
return boundedRuleOffers(parsed, { countRedactions: false });
|
|
126
161
|
}
|
|
127
162
|
function readRiskDescriptorCell(cell) {
|
|
163
|
+
if (cell === null)
|
|
164
|
+
return null;
|
|
128
165
|
try {
|
|
129
|
-
return
|
|
166
|
+
return decodeJsonColumn(cell, readRiskDescriptorBody, "checkpoint.risk_descriptor (operator queue row)");
|
|
130
167
|
}
|
|
131
|
-
catch {
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (!isJsonColumnBodyProblem(e))
|
|
170
|
+
throw e;
|
|
132
171
|
recordFailOpen("server.approvals.risk-descriptor-cell-unreadable");
|
|
133
172
|
return null;
|
|
134
173
|
}
|
|
@@ -166,23 +205,16 @@ export function approvalDecisionOfWinner(winner, boundCallId) {
|
|
|
166
205
|
}
|
|
167
206
|
}
|
|
168
207
|
}
|
|
169
|
-
export function pendingActionToolCallId(
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
parsed = typeof blob === "string" ? JSON.parse(blob) : blob;
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
return { readable: false };
|
|
176
|
-
}
|
|
177
|
-
if (typeof parsed !== "object" || parsed === null)
|
|
208
|
+
export function pendingActionToolCallId(body) {
|
|
209
|
+
if (typeof body !== "object" || body === null)
|
|
178
210
|
return { readable: false };
|
|
179
|
-
const rawSource = "sourceTaskId" in
|
|
211
|
+
const rawSource = "sourceTaskId" in body ? body.sourceTaskId : null;
|
|
180
212
|
if (rawSource !== null && rawSource !== undefined && typeof rawSource !== "string")
|
|
181
213
|
return { readable: false };
|
|
182
214
|
const sourceTaskId = typeof rawSource === "string" ? rawSource : null;
|
|
183
|
-
if (!("pendingAction" in
|
|
215
|
+
if (!("pendingAction" in body))
|
|
184
216
|
return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId };
|
|
185
|
-
const pa =
|
|
217
|
+
const pa = body.pendingAction;
|
|
186
218
|
if (typeof pa !== "object" || pa === null)
|
|
187
219
|
return { readable: false };
|
|
188
220
|
const kind = "kind" in pa ? pa.kind : null;
|
|
@@ -200,6 +232,18 @@ export function pendingActionToolCallId(blob) {
|
|
|
200
232
|
return { readable: false };
|
|
201
233
|
return { readable: true, toolCallId: id, boundInputHash: bih, kind, sourceTaskId };
|
|
202
234
|
}
|
|
235
|
+
function pendingActionOfColumn(cell, where) {
|
|
236
|
+
let body;
|
|
237
|
+
try {
|
|
238
|
+
body = decodeJsonColumn(cell, readJsonObjectColumn, `checkpoint.checkpoint ${where}`);
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
if (!isJsonColumnBodyProblem(e))
|
|
242
|
+
throw e;
|
|
243
|
+
return { readable: false };
|
|
244
|
+
}
|
|
245
|
+
return pendingActionToolCallId(body);
|
|
246
|
+
}
|
|
203
247
|
function readActorAssertion(v) {
|
|
204
248
|
if (typeof v !== "object" || v === null)
|
|
205
249
|
return undefined;
|
|
@@ -236,33 +280,24 @@ function readQueueEntry(v) {
|
|
|
236
280
|
}
|
|
237
281
|
export function readPendingSteerQueueColumn(queueRaw) {
|
|
238
282
|
let lossy = false;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
lossy = true;
|
|
249
|
-
return undefined;
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
const queueParsed = parse(queueRaw);
|
|
283
|
+
let items;
|
|
284
|
+
try {
|
|
285
|
+
items = decodeNullableJsonColumn(queueRaw, readJsonArrayBody, "checkpoint.pending_steer_queue");
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
if (!isJsonColumnBodyProblem(e))
|
|
289
|
+
throw e;
|
|
290
|
+
lossy = true;
|
|
291
|
+
}
|
|
253
292
|
let queue;
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
lossy = true;
|
|
263
|
-
else
|
|
264
|
-
queue.push(entry);
|
|
265
|
-
}
|
|
293
|
+
if (items !== undefined) {
|
|
294
|
+
queue = [];
|
|
295
|
+
for (const item of items) {
|
|
296
|
+
const entry = readQueueEntry(item);
|
|
297
|
+
if (entry === undefined)
|
|
298
|
+
lossy = true;
|
|
299
|
+
else
|
|
300
|
+
queue.push(entry);
|
|
266
301
|
}
|
|
267
302
|
}
|
|
268
303
|
return { state: { ...(queue !== undefined ? { pendingSteerQueue: queue } : {}) }, lossy };
|
|
@@ -359,7 +394,9 @@ export class SqlCheckpointStore {
|
|
|
359
394
|
}
|
|
360
395
|
}
|
|
361
396
|
async get(token) {
|
|
362
|
-
const { rows } = await this.db.query(this.q(
|
|
397
|
+
const { rows } = await this.db.query(this.q(`SELECT version, status, ${jsonColumnText("tidb", "checkpoint")}, ${jsonColumnText("tidb", "outcome")}, reopen_reason, rev, ` +
|
|
398
|
+
`${jsonColumnText("tidb", "pending_steer_queue")}, ${jsonColumnText("tidb", "execution_outcome")}, execution_at_ms FROM checkpoint WHERE token = ?`, `SELECT version, status, ${jsonColumnText("pg", "checkpoint")}, ${jsonColumnText("pg", "outcome")}, reopen_reason, rev, ` +
|
|
399
|
+
`${jsonColumnText("pg", "pending_steer_queue")}, ${jsonColumnText("pg", "execution_outcome")}, execution_at_ms FROM checkpoint WHERE token = $1`), [token]);
|
|
363
400
|
const r = rows[0];
|
|
364
401
|
if (!r)
|
|
365
402
|
return null;
|
|
@@ -367,21 +404,22 @@ export class SqlCheckpointStore {
|
|
|
367
404
|
if (version > MAX_SUPPORTED_CHECKPOINT_VERSION) {
|
|
368
405
|
throw new Error(`checkpoint format version ${version} is newer than this build supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION}) — upgrade the service to resume it`);
|
|
369
406
|
}
|
|
370
|
-
const cp =
|
|
407
|
+
const cp = refusing("a checkpoint nobody can resume", () => decodeCheckpointColumn(r.checkpoint, `for token ${tokenFingerprint(token)}`));
|
|
371
408
|
cp.status = r.status;
|
|
372
|
-
const outcomeRaw =
|
|
409
|
+
const outcomeRaw = refusing("a decision record nobody can read", () => decodeOutcomeColumn(r.outcome, `for token ${tokenFingerprint(token)}`));
|
|
373
410
|
if (outcomeRaw)
|
|
374
411
|
cp.resolvedOutcome = winnerFromOutcome(outcomeRaw);
|
|
375
412
|
cp.reopenReason = r.reopen_reason ?? undefined;
|
|
376
413
|
cp.rev = r.rev == null ? 0 : Number(r.rev);
|
|
377
|
-
const executionOutcome =
|
|
378
|
-
if (executionOutcome !==
|
|
414
|
+
const executionOutcome = refusing("an execution record nobody can read", () => decodeExecutionOutcomeColumn(r.execution_outcome, `for token ${tokenFingerprint(token)}`));
|
|
415
|
+
if (executionOutcome !== undefined) {
|
|
379
416
|
cp.executionOutcome = executionOutcome;
|
|
380
417
|
if (r.execution_at_ms != null)
|
|
381
418
|
cp.executionAtMs = Number(r.execution_at_ms);
|
|
382
419
|
}
|
|
383
420
|
const steer = readPendingSteerQueueColumn(r.pending_steer_queue);
|
|
384
421
|
if (steer.lossy) {
|
|
422
|
+
recordFailOpen("server.sql-json.checkpoint.pending-steer-queue-lossy");
|
|
385
423
|
this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
|
|
386
424
|
}
|
|
387
425
|
if (cp.state) {
|
|
@@ -412,14 +450,14 @@ export class SqlCheckpointStore {
|
|
|
412
450
|
}
|
|
413
451
|
async recordExecutionOutcome(token, scope, gate) {
|
|
414
452
|
for (let attempt = 0; attempt < EXECUTION_RECORD_CAS_ATTEMPTS; attempt++) {
|
|
415
|
-
const { rows } = await this.db.query(this.q(
|
|
453
|
+
const { rows } = await this.db.query(this.q(`SELECT scope, status, ${jsonColumnText("tidb", "execution_outcome")} FROM checkpoint WHERE token = ?`, `SELECT scope, status, ${jsonColumnText("pg", "execution_outcome")} FROM checkpoint WHERE token = $1`), [token]);
|
|
416
454
|
const row = rows[0];
|
|
417
455
|
const facts = row === undefined
|
|
418
456
|
? undefined
|
|
419
457
|
: {
|
|
420
458
|
scope: String(row.scope),
|
|
421
459
|
status: row.status,
|
|
422
|
-
...((v) => (v ===
|
|
460
|
+
...((v) => (v === undefined ? {} : { executionOutcome: v }))(refusing("an execution record nobody can read", () => decodeExecutionOutcomeColumn(row.execution_outcome, `for token ${tokenFingerprint(token)}`))),
|
|
423
461
|
};
|
|
424
462
|
const disposition = executionRecordDisposition(facts, scope, gate);
|
|
425
463
|
if (disposition === "conflict")
|
|
@@ -436,7 +474,7 @@ export class SqlCheckpointStore {
|
|
|
436
474
|
async setPendingSteer(token, scope, steer) {
|
|
437
475
|
const clean = validatePendingSteer(steer);
|
|
438
476
|
for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
|
|
439
|
-
const { rows } = await this.db.query(this.q(
|
|
477
|
+
const { rows } = await this.db.query(this.q(`SELECT status, ${jsonColumnText("tidb", "pending_steer_queue")}, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?`, `SELECT status, ${jsonColumnText("pg", "pending_steer_queue")}, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2`), [token, scope]);
|
|
440
478
|
const row = rows[0];
|
|
441
479
|
if (!row || row.status !== "pending")
|
|
442
480
|
return false;
|
|
@@ -480,7 +518,9 @@ export class SqlCheckpointStore {
|
|
|
480
518
|
return rows.map((r) => ({ sessionId: String(r.session_id), scope: String(r.scope) }));
|
|
481
519
|
}
|
|
482
520
|
async listPending(scope) {
|
|
483
|
-
const
|
|
521
|
+
const d = this.db.dialect;
|
|
522
|
+
const base = `SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, ${jsonColumnText(d, "tool_input")}, c.bound_input_hash, ` +
|
|
523
|
+
`${jsonColumnText(d, "risk_descriptor")}, ${jsonColumnText(d, "rule_suggestions")}, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id ` +
|
|
484
524
|
"FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
|
|
485
525
|
const { rows } = scope
|
|
486
526
|
? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
|
|
@@ -500,7 +540,7 @@ export class SqlCheckpointStore {
|
|
|
500
540
|
...(boundInputHash !== null ? { boundInputHash } : {}),
|
|
501
541
|
...(gateKind !== null ? { gateKind } : {}),
|
|
502
542
|
taskId: r.task_id ?? null,
|
|
503
|
-
input: r.tool_input ?? null,
|
|
543
|
+
input: refusing("a tool input the operator would be approving unseen", () => decodeToolInputColumn(r.tool_input)) ?? null,
|
|
504
544
|
createdAt: Number(r.created_at_ms),
|
|
505
545
|
deadline: r.deadline == null ? null : Number(r.deadline),
|
|
506
546
|
riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
|
|
@@ -512,12 +552,10 @@ export class SqlCheckpointStore {
|
|
|
512
552
|
return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
|
|
513
553
|
}
|
|
514
554
|
async listByScope(scope) {
|
|
515
|
-
const { rows } = await this.db.query(this.q(
|
|
555
|
+
const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "checkpoint")}, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500`, `SELECT ${jsonColumnText("pg", "checkpoint")}, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500`), [scope]);
|
|
516
556
|
const out = [];
|
|
517
557
|
for (const r of rows) {
|
|
518
|
-
const cp =
|
|
519
|
-
if (!cp)
|
|
520
|
-
continue;
|
|
558
|
+
const cp = refusing("a checkpoint nobody can resume", () => decodeCheckpointColumn(r.checkpoint, `in the ${scope} scheduler view`));
|
|
521
559
|
cp.status = r.status;
|
|
522
560
|
out.push(summarizeCheckpoint(cp));
|
|
523
561
|
}
|
|
@@ -529,8 +567,8 @@ export class SqlCheckpointStore {
|
|
|
529
567
|
return rows[0] ? String(rows[0].token) : null;
|
|
530
568
|
}
|
|
531
569
|
async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
|
|
532
|
-
const { rows } = await this.db.query(this.q(
|
|
533
|
-
"WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC",
|
|
570
|
+
const { rows } = await this.db.query(this.q(`SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, ${jsonColumnText("tidb", "checkpoint")} FROM checkpoint ` +
|
|
571
|
+
"WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", `SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, ${jsonColumnText("pg", "checkpoint")} FROM checkpoint ` +
|
|
534
572
|
"WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
|
|
535
573
|
const out = [];
|
|
536
574
|
for (const r of rows) {
|
|
@@ -545,7 +583,7 @@ export class SqlCheckpointStore {
|
|
|
545
583
|
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
546
584
|
continue;
|
|
547
585
|
}
|
|
548
|
-
const derived =
|
|
586
|
+
const derived = pendingActionOfColumn(r.checkpoint, `for token ${tokenFingerprint(String(r.token))}`);
|
|
549
587
|
if (!derived.readable) {
|
|
550
588
|
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
551
589
|
continue;
|
|
@@ -567,24 +605,31 @@ export class SqlCheckpointStore {
|
|
|
567
605
|
return out;
|
|
568
606
|
}
|
|
569
607
|
async findDecidedApprovalsForBinding(sessionId, boundCallId) {
|
|
570
|
-
const { rows } = await this.db.query(this.q(
|
|
571
|
-
`WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`,
|
|
608
|
+
const { rows } = await this.db.query(this.q(`SELECT scope, bound_input_hash, ${jsonColumnText("tidb", "outcome")}, ${jsonColumnText("tidb", "execution_outcome")} FROM checkpoint ` +
|
|
609
|
+
`WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, `SELECT scope, bound_input_hash, ${jsonColumnText("pg", "outcome")}, ${jsonColumnText("pg", "execution_outcome")} FROM checkpoint ` +
|
|
572
610
|
`WHERE session_id=$1 AND tool_call_id=$2 AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`), [sessionId, boundCallId]);
|
|
573
611
|
return rows.map((r) => {
|
|
574
612
|
let winner;
|
|
575
613
|
try {
|
|
576
|
-
const outcome =
|
|
614
|
+
const outcome = decodeOutcomeColumn(r.outcome, `for the decided row of session ${sessionId}`);
|
|
577
615
|
winner = outcome ? winnerFromOutcome(outcome) : undefined;
|
|
578
616
|
}
|
|
579
617
|
catch (e) {
|
|
618
|
+
if (!isJsonColumnBodyProblem(e))
|
|
619
|
+
throw e;
|
|
620
|
+
recordFailOpen("server.sql-json.checkpoint.outcome-unreadable");
|
|
580
621
|
this.logger?.info?.("checkpoint_decided_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
|
|
581
622
|
}
|
|
582
623
|
let executed = null;
|
|
583
624
|
try {
|
|
584
|
-
const
|
|
625
|
+
const cell = decodeExecutionOutcomeColumn(r.execution_outcome, `for the decided row of session ${sessionId}`);
|
|
626
|
+
const verdict = executionVerdict({ ...((v) => (v === undefined ? {} : { executionOutcome: v }))(cell) });
|
|
585
627
|
executed = verdict.kind === "executed" ? verdict.gate : null;
|
|
586
628
|
}
|
|
587
629
|
catch (e) {
|
|
630
|
+
if (!isJsonColumnBodyProblem(e))
|
|
631
|
+
throw e;
|
|
632
|
+
recordFailOpen("server.sql-json.checkpoint.execution-outcome-unreadable");
|
|
588
633
|
this.logger?.info?.("checkpoint_execution_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
|
|
589
634
|
}
|
|
590
635
|
return {
|
|
@@ -615,8 +660,8 @@ export class SqlCheckpointStore {
|
|
|
615
660
|
"ON CONFLICT (session_id) DO UPDATE SET ctx = EXCLUDED.ctx, updated_at_ms = EXCLUDED.updated_at_ms"), [sessionId, this.json(ctx, "checkpoint ctx"), Date.now()]);
|
|
616
661
|
}
|
|
617
662
|
async getCtx(sessionId) {
|
|
618
|
-
const { rows } = await this.db.query(this.q(
|
|
619
|
-
return rows[0] ?
|
|
663
|
+
const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "ctx")} FROM checkpoint_ctx WHERE session_id=?`, `SELECT ${jsonColumnText("pg", "ctx")} FROM checkpoint_ctx WHERE session_id=$1`), [sessionId]);
|
|
664
|
+
return rows[0] ? refusing("a resume context nobody can read", () => decodeCtxColumn(rows[0].ctx, `for session ${JSON.stringify(sessionId)}`)) : null;
|
|
620
665
|
}
|
|
621
666
|
async reapCtx(cutoff) {
|
|
622
667
|
const res = await this.db.query(this.q("DELETE FROM checkpoint_ctx WHERE updated_at_ms < ? " +
|
|
@@ -186,6 +186,16 @@ export declare class SqlDeviceStore implements DeviceStore {
|
|
|
186
186
|
appendAudit(input: AppendAuditInput): Promise<void>;
|
|
187
187
|
/** 审计写的**唯一**语句(池级与事务内共用;`SqlExec` 是两者的公共面)。 */
|
|
188
188
|
private insertAuditOn;
|
|
189
|
+
/**
|
|
190
|
+
* 审计列表。
|
|
191
|
+
*
|
|
192
|
+
* 🔴 **一行读不出形 ⇒ 整页拒,刻意不做逐行隔离**(S-550 修复验证轮,主会话裁定后入档):方向由设计
|
|
193
|
+
* §2「失败方向 = 拒(fail-closed),全店同向」定。逐行隔离(坏行跳过 / 占位)在**审计**面上尤其危险 ——
|
|
194
|
+
* 它把「这一页少了一条」变得和「本来就没有那一条」同形,而审计面的全部意义就是「发生过的事都在」。
|
|
195
|
+
* 一页 5xx 是响亮的运维事件,一条静默消失不是。
|
|
196
|
+
*
|
|
197
|
+
* ⇒ 存量库里旧写腿留下的坏字节由**一次性迁移**清掉(`one-time-migrations.ts` M4),不靠读侧容忍。
|
|
198
|
+
*/
|
|
189
199
|
listAudit(filter?: ListAuditFilter): Promise<DeviceAuditRow[]>;
|
|
190
200
|
}
|
|
191
201
|
/** 方言绑定薄壳(A12 形:ctor 收池,行为全在基类)。 */
|
|
@@ -2,6 +2,7 @@ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
|
2
2
|
import { isDupKeyError, isCheckViolationError } from "./sql-errors.js";
|
|
3
3
|
import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRebindShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
|
|
4
4
|
import { ensureIndexes } from "./ensure-index.js";
|
|
5
|
+
import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
|
|
5
6
|
export const DEVICES_TABLE = "device";
|
|
6
7
|
export const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
|
|
7
8
|
export const DEVICE_SESSIONS_TABLE = "device_session";
|
|
@@ -237,7 +238,7 @@ function sessionCols(dialect) {
|
|
|
237
238
|
return ["root_session_id", "device_id", "owner_tenant", "owner_subject", ms(dialect, "bound_at", "bound_at_ms"), "rev"].join(", ");
|
|
238
239
|
}
|
|
239
240
|
function auditCols(dialect) {
|
|
240
|
-
return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", "detail"].join(", ");
|
|
241
|
+
return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", jsonColumnText(dialect, "detail")].join(", ");
|
|
241
242
|
}
|
|
242
243
|
function bufToStr(v) {
|
|
243
244
|
return Buffer.isBuffer(v) ? v.toString("utf8") : String(v);
|
|
@@ -295,9 +296,6 @@ function mapSessionRow(r) {
|
|
|
295
296
|
rev: Number(r.rev),
|
|
296
297
|
};
|
|
297
298
|
}
|
|
298
|
-
function parseJsonColumn(v) {
|
|
299
|
-
return JSON.parse(v);
|
|
300
|
-
}
|
|
301
299
|
function mapAuditRow(r) {
|
|
302
300
|
const tenant = strOrNull(r.owner_tenant);
|
|
303
301
|
const subject = strOrNull(r.owner_subject);
|
|
@@ -308,9 +306,17 @@ function mapAuditRow(r) {
|
|
|
308
306
|
event: parseDeviceAuditEvent(bufToStr(r.event)),
|
|
309
307
|
deviceId: strOrNull(r.device_id),
|
|
310
308
|
owner: tenant !== null && subject !== null ? { tenant, subject } : null,
|
|
311
|
-
detail: rawDetail === null || rawDetail === undefined ? null :
|
|
309
|
+
detail: rawDetail === null || rawDetail === undefined ? null : readAuditDetail(rawDetail, String(r.id)),
|
|
312
310
|
};
|
|
313
311
|
}
|
|
312
|
+
function readAuditDetail(raw, id) {
|
|
313
|
+
try {
|
|
314
|
+
return decodeJsonColumn(raw, readJsonObjectColumn, `${DEVICE_AUDIT_TABLE}.detail for audit row ${id}`);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
throw foldJsonColumn(e, (m) => new Error(`device-store: ${m} — refusing rather than serve an audit row whose detail nobody can read`));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
314
320
|
function ph(dialect, n) {
|
|
315
321
|
return dialect === "tidb" ? "?" : `$${n}`;
|
|
316
322
|
}
|
|
@@ -637,7 +643,7 @@ export class SqlDeviceStore {
|
|
|
637
643
|
assertDeviceIdShape(input.deviceId);
|
|
638
644
|
if (input.owner)
|
|
639
645
|
assertDeviceOwnerShape(input.owner);
|
|
640
|
-
await exec.query(this.q(`INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, ?, ?, ?, ?)`, `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, $2, $3, $4, $5::jsonb)`), [input.event, input.deviceId ?? null, input.owner?.tenant ?? null, input.owner?.subject ?? null, input.detail
|
|
646
|
+
await exec.query(this.q(`INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, ?, ?, ?, ?)`, `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, $2, $3, $4, $5::jsonb)`), [input.event, input.deviceId ?? null, input.owner?.tenant ?? null, input.owner?.subject ?? null, input.detail == null ? null : this.json(input.detail)]);
|
|
641
647
|
}
|
|
642
648
|
async listAudit(filter) {
|
|
643
649
|
const dialect = this.db.dialect;
|
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-lane PLATFORM seam (DESIGN-windows-native.md, FINAL r3) — the ONE place the host exec adapter's
|
|
3
|
+
* POSIX/win32 differences live, so `remote-env-host.ts` stays a single code path with platform-gated leaves.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 Iron invariant (design §4.1): the POSIX path is BYTE-IDENTICAL to the pre-Windows code — every helper is
|
|
6
|
+
* `win32 ? new : exactly-what-the-inline-code-did` (same syscall, same throw behavior). Never "improve" POSIX
|
|
7
|
+
* here; the existing full test suite is the regression net.
|
|
8
|
+
*
|
|
9
|
+
* win32 semantics (design D1-D4):
|
|
10
|
+
* - shell = Git Bash via core 1.224 `getShellConfig` (WSL-launcher-filtered — the `System32\bash.exe`
|
|
11
|
+
* trap). Fail-LOUD when absent; never silently degrade to cmd (D1).
|
|
12
|
+
* - kill = core 1.224 `signalProcessTree` (taskkill /T, /F for hard). Soft is a no-op for console trees
|
|
13
|
+
* (taskkill errors "can only be terminated forcefully") — the SIGTERM→grace→SIGKILL ladder is
|
|
14
|
+
* effectively delay→hard-kill on win32; accepted, CC-identical (D2).
|
|
15
|
+
* - spawn = `detached:false` + `windowsHide:true` (no console window; no POSIX process group — the kill
|
|
16
|
+
* side uses the tree, not the group) (D3).
|
|
17
|
+
* - env = case-insensitive key collapse before spawn (win32 env keys are case-insensitive; a `Path`+`PATH`
|
|
18
|
+
* pair from case-sensitive Object.assign reaches CreateProcess as ONE undefined-which
|
|
19
|
+
* entry). Canonical casing = the first-seen key (process.env's native casing wins since the
|
|
20
|
+
* inherit base is spread first).
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ The win32 branches are UNVERIFIED on a real machine until S5 (Windows CI runner) — design D5 discipline:
|
|
23
|
+
* structural tests only on mac/Linux; behavior-level bite happens on the first Windows-runner green.
|
|
24
|
+
*/
|
|
25
|
+
import type { ChildProcess } from "node:child_process";
|
|
1
26
|
import { killProcessTree } from "@sema-agent/core";
|
|
2
27
|
export declare const IS_WIN32: boolean;
|
|
3
28
|
export interface HostShell {
|
|
@@ -48,15 +73,52 @@ export declare function killTreeSoft(pid: number): void;
|
|
|
48
73
|
/** Graceful tree kill where the CALLER has no escalation timer of its own (currently unused by the host lane —
|
|
49
74
|
* exported for parity with core's surface; the bg driver keeps its own HOST_BG_KILL_GRACE_MS ladder). */
|
|
50
75
|
export { killProcessTree };
|
|
51
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* 登记回执 = 注销的**唯一**凭据(身份即凭据,见簿注)+ 一只幂等的注销把手。
|
|
78
|
+
*
|
|
79
|
+
* S-566 归一轮:注销**只有这一张脸**(`release()`)。此前另有一只 `untrackHostForegroundChild(ticket)`
|
|
80
|
+
* 自由函数,于是「把这只回执从簿上删掉」有两种写法 —— 零别名纪律下这属于歧义面,已**整个删除**
|
|
81
|
+
* (硬 breaking 三句:没有别名、没有过渡方法,调用方的 tsc 红就是通知)。
|
|
82
|
+
*/
|
|
52
83
|
export interface ForegroundChildTicket {
|
|
53
84
|
/** 组领袖 pid = pgid(POSIX `detached:true` 的直接后果)。 */
|
|
54
85
|
readonly pid: number;
|
|
86
|
+
/**
|
|
87
|
+
* 提前注销 —— 给各腿**特有**的兜底时点用(settle / `close` / 领养移交 / 生成器 `finally`)。
|
|
88
|
+
* 幂等(同一只回执 release 多少次都一样),且按**身份**删 ⇒ 迟到的那一次**绝不**碰内核复用同号之后
|
|
89
|
+
* 的新登记。主时点(`exit` / `error`)由 {@link adoptHostForegroundChild} 自己挂,调用点不必重复写。
|
|
90
|
+
*/
|
|
91
|
+
release(): void;
|
|
55
92
|
}
|
|
56
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* 登记一只自起的前台子进程(**低层原语**:只有 pid 在手时用它 —— 产品码一律走
|
|
95
|
+
* {@link adoptHostForegroundChild},见那条形状钉)。
|
|
96
|
+
*/
|
|
57
97
|
export declare function trackHostForegroundChild(pid: number): ForegroundChildTicket;
|
|
58
|
-
/**
|
|
59
|
-
|
|
98
|
+
/**
|
|
99
|
+
* 🔴 **本簿在 `src/` 的唯一登记口**(S-566 归一轮)—— 一只自起的 detached 前台子进程「进簿 + 两只主注销」
|
|
100
|
+
* 的**全部**语义收在这里。
|
|
101
|
+
*
|
|
102
|
+
* ── 为什么是一只函数而不是五处同一句 ────────────────────────────────────────────────────────────
|
|
103
|
+
* 归一前,这一句在仓里**手抄五份**(`remote-env-host.ts` 三条 fg 腿 + `hook-runner.ts` 两条):
|
|
104
|
+
* 「`pid != null` 才登记」「`exit` 主注销」「`error` 之后 `exit` 可能不来,所以也要注销」——
|
|
105
|
+
* 每多一条 detached 腿就要再抄一遍,而**第六处只要漏抄一臂就是一个静默的孤儿洞**(S-566 的病正是
|
|
106
|
+
* hook 那两条腿整份没抄)。一条规则答完所有腿,规则集因此变小而不是变大。
|
|
107
|
+
*
|
|
108
|
+
* ── 语义(三条,逐条有理由)────────────────────────────────────────────────────────────────────
|
|
109
|
+
* · **无 pid ⇒ `undefined`,不登记**:spawn 起手失败那一族根本没有组领袖,簿上放一个 `undefined` 的坐标
|
|
110
|
+
* 等于给收割腿一条假线索;调用点因此拿到的是一个「可能不在」的把手,漏判会被类型挡住。
|
|
111
|
+
* · **`exit` = 主注销时点**(不是 `close` / settle):`close` 是**管道**驱动的,一个继承了 stdout 的孙进程
|
|
112
|
+
* 能把它按住任意久;而一条已退的 pid 留在簿里 = 给收割腿一个**过期坐标** —— 内核复用那个号之后
|
|
113
|
+
* `kill(-pid)` 打的是**别人**的组。
|
|
114
|
+
* · **`error` 也注销**:Node 契约里 `error` 之后 `exit` **可能不来**(异步起手失败),那一形没有第二次机会。
|
|
115
|
+
* 本函数只挂注销,**不**吞 `error` —— 五条腿各自的 `error` 处置(记账 / settle / 铸故障帧)原样在位,
|
|
116
|
+
* 本监听是第 N 只监听器,不改变「`error` 有没有人接」这件事(亲核:五处在归一前就都已有 `error` 监听)。
|
|
117
|
+
*
|
|
118
|
+
* 各腿**特有**的兜底(settle / `close` / 领养移交 / 生成器 `finally`)留在调用点,写成一行
|
|
119
|
+
* `ticket?.release()` —— 它们是**那条腿自己的**生命周期事实,不是本簿的语义。
|
|
120
|
+
*/
|
|
121
|
+
export declare function adoptHostForegroundChild(child: ChildProcess): ForegroundChildTicket | undefined;
|
|
60
122
|
/** 一次前台收割的读数。三格分开是**刻意**的:`alreadyDead` 是想要的结果(不是失败),`failed` 是**真的**
|
|
61
123
|
* 没收上来 —— 两者混成一个数就等于把后者静默掉([ref]:这条腿上不许有安静的兜底)。
|
|
62
124
|
*
|
|
@@ -75,8 +137,10 @@ export interface ForegroundReapReading {
|
|
|
75
137
|
* 收割簿上全部前台子进程组。返回**发出**信号的读数,不是确认死亡数 —— 确认要等,而本函数的调用点之一
|
|
76
138
|
* 是 uncaught 出口。
|
|
77
139
|
*
|
|
78
|
-
* **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句)
|
|
79
|
-
* `process.once("exit")` 钩 ——
|
|
140
|
+
* **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句):承重的调用点是退出边界装配器
|
|
141
|
+
* (`boot/exit-boundary.ts`)那只 `process.once("exit")` 钩 —— S-560 起它由**两条车道共用**(HTTP 服务的
|
|
142
|
+
* 收尾链 + `run-local` 一次性 CLI;后者另装 SIGINT/SIGTERM 处理器,因为前台 CLI 的 Ctrl-C 若走默认终止,
|
|
143
|
+
* `exit` 钩不触发)。`exit` 之后**没有续段**,`await` 一个宽限梯就是把收割赌在一个永远等不到的
|
|
80
144
|
* 时刻上(`hardShutdown` 终点那次早按只是优化,见 `boot/shutdown.ts` 的两处注)。上界 = 簿上条数 ×
|
|
81
145
|
* 一次 `kill(-pid)` syscall(簿条数被 host lane 的在飞 turn 数界住),没有定时器、没有 promise。
|
|
82
146
|
*
|
|
@@ -56,12 +56,23 @@ export function killTreeSoft(pid) {
|
|
|
56
56
|
export { killProcessTree };
|
|
57
57
|
const liveHostForegroundGroups = new Set();
|
|
58
58
|
export function trackHostForegroundChild(pid) {
|
|
59
|
-
const ticket = {
|
|
59
|
+
const ticket = {
|
|
60
|
+
pid,
|
|
61
|
+
release() {
|
|
62
|
+
liveHostForegroundGroups.delete(ticket);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
60
65
|
liveHostForegroundGroups.add(ticket);
|
|
61
66
|
return ticket;
|
|
62
67
|
}
|
|
63
|
-
export function
|
|
64
|
-
|
|
68
|
+
export function adoptHostForegroundChild(child) {
|
|
69
|
+
const pid = child.pid;
|
|
70
|
+
if (pid == null)
|
|
71
|
+
return undefined;
|
|
72
|
+
const ticket = trackHostForegroundChild(pid);
|
|
73
|
+
child.once("exit", () => ticket.release());
|
|
74
|
+
child.once("error", () => ticket.release());
|
|
75
|
+
return ticket;
|
|
65
76
|
}
|
|
66
77
|
export function reapHostForegroundChildren() {
|
|
67
78
|
let signalled = 0;
|
|
@@ -2,7 +2,7 @@ import { uuidv7 } from "@sema-agent/core";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { isDupKeyError } from "./sql-errors.js";
|
|
4
4
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
5
|
-
import {
|
|
5
|
+
import { decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readNonNullJsonValueColumn } from "./sql-json-column.js";
|
|
6
6
|
import { ensureIndexes } from "./ensure-index.js";
|
|
7
7
|
export const LEADER_RUN_TABLE = "leader_run";
|
|
8
8
|
const LEADER_RUN_STATUSES = new Set(["running", "completed", "failed", "needs_human"]);
|
|
@@ -59,7 +59,17 @@ export async function ensurePgLeaderRunSchema(q) {
|
|
|
59
59
|
}
|
|
60
60
|
export const STALE_SWEEP_REASON = "leader run went stale: no replica settled it within LEADER_RUN_STALE_MS (the replica driving it most likely died). " +
|
|
61
61
|
"The run was NOT taken over — re-submit with a NEW Idempotency-Key to run it again.";
|
|
62
|
-
|
|
62
|
+
function selectCols(dialect) {
|
|
63
|
+
return `leader_run_id, owner, objective_preview, status, ${jsonColumnText(dialect, "result")}, error, started_at_ms, finished_at_ms`;
|
|
64
|
+
}
|
|
65
|
+
function readResultColumn(text, id) {
|
|
66
|
+
try {
|
|
67
|
+
return decodeNullableJsonColumn(text, readNonNullJsonValueColumn, `${LEADER_RUN_TABLE}.result for leader run ${id}`);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
throw foldJsonColumn(e, (m) => new Error(`leader_run: ${m} — refusing rather than serve this run as if it had finished empty-handed`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
63
73
|
function statusOf(raw, id) {
|
|
64
74
|
const s = String(raw);
|
|
65
75
|
if (!LEADER_RUN_STATUSES.has(s)) {
|
|
@@ -69,7 +79,7 @@ function statusOf(raw, id) {
|
|
|
69
79
|
}
|
|
70
80
|
function mapRow(r) {
|
|
71
81
|
const id = String(r.leader_run_id);
|
|
72
|
-
const result =
|
|
82
|
+
const result = readResultColumn(r.result, id);
|
|
73
83
|
const error = r.error == null ? undefined : String(r.error);
|
|
74
84
|
const finished = r.finished_at_ms == null ? undefined : Number(r.finished_at_ms);
|
|
75
85
|
return {
|
|
@@ -113,12 +123,12 @@ export class SqlLeaderRunStore {
|
|
|
113
123
|
return { created: true, run };
|
|
114
124
|
}
|
|
115
125
|
async getRun(id) {
|
|
116
|
-
const res = await this.db.query(this.q(`SELECT ${
|
|
126
|
+
const res = await this.db.query(this.q(`SELECT ${selectCols("tidb")} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = ?`, `SELECT ${selectCols("pg")} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = $1`), [id]);
|
|
117
127
|
const row = res.rows[0];
|
|
118
128
|
return row ? mapRow(row) : undefined;
|
|
119
129
|
}
|
|
120
130
|
async getByIdem(idemKey) {
|
|
121
|
-
const res = await this.db.query(this.q(`SELECT ${
|
|
131
|
+
const res = await this.db.query(this.q(`SELECT ${selectCols("tidb")} FROM ${LEADER_RUN_TABLE} WHERE idem_key = ?`, `SELECT ${selectCols("pg")} FROM ${LEADER_RUN_TABLE} WHERE idem_key = $1`), [idemKey]);
|
|
122
132
|
const row = res.rows[0];
|
|
123
133
|
return row ? mapRow(row) : undefined;
|
|
124
134
|
}
|
|
@@ -132,7 +142,7 @@ export class SqlLeaderRunStore {
|
|
|
132
142
|
}
|
|
133
143
|
async finishRun(id, patch) {
|
|
134
144
|
const json = dialectJsonEncoder(this.db.dialect);
|
|
135
|
-
const res = await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET status = ?, result = CAST(? AS JSON), error = ?, finished_at_ms = ? WHERE leader_run_id = ? AND status = 'running'`, `UPDATE ${LEADER_RUN_TABLE} SET status = $1, result = $2::jsonb, error = $3, finished_at_ms = $4 WHERE leader_run_id = $5 AND status = 'running'`), [patch.status, patch.result
|
|
145
|
+
const res = await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET status = ?, result = CAST(? AS JSON), error = ?, finished_at_ms = ? WHERE leader_run_id = ? AND status = 'running'`, `UPDATE ${LEADER_RUN_TABLE} SET status = $1, result = $2::jsonb, error = $3, finished_at_ms = $4 WHERE leader_run_id = $5 AND status = 'running'`), [patch.status, patch.result == null ? null : json(patch.result), patch.error ?? null, Date.now(), id]);
|
|
136
146
|
return res.affected > 0;
|
|
137
147
|
}
|
|
138
148
|
}
|
|
@@ -13,6 +13,9 @@ export interface OneTimeMigrationReport {
|
|
|
13
13
|
readonly retiredSteerSeatStaleRows: number;
|
|
14
14
|
/** M3:改名的行数(账本 `tool_end` 行 + 已决 checkpoint 的执行判词列,两者相加)。 */
|
|
15
15
|
readonly askOriginRowsMigrated: number;
|
|
16
|
+
/** M4:`device_audit.detail` 里 JSON 文本 `null` 被改成列 NULL 的行数(表不在 ⇒ 恒 0)。
|
|
17
|
+
* 非零 = 这次迁移真改写了存量字节,必须让运维看见(与 M1 的 `retiredSnapshotRows` 同一条纪律)。 */
|
|
18
|
+
readonly auditDetailJsonNullRows: number;
|
|
16
19
|
}
|
|
17
20
|
/** 三条迁移都不适用的载体(local 车道没有 SQL 表)。 */
|
|
18
21
|
export declare const NO_SQL_MIGRATIONS: Omit<OneTimeMigrationReport, "askOriginRowsMigrated">;
|