@sema-agent/server 7.93.7 → 7.94.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/dist/boot/stores.js +6 -3
- package/dist/boot/workflow-orchestration.js +5 -1
- package/dist/http/resume-ctx-read.d.ts +40 -0
- package/dist/http/resume-ctx-read.js +25 -0
- package/dist/http/resume-legs.js +24 -14
- package/dist/http/routes/images.js +12 -0
- package/dist/http/routes/runs.js +18 -4
- package/dist/http/routes/workflows.js +3 -2
- package/dist/observability/corrupt-read-seat.d.ts +56 -0
- package/dist/observability/corrupt-read-seat.js +16 -0
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/orchestration/workflow-journal-entry.d.ts +24 -6
- package/dist/orchestration/workflow-journal-entry.js +17 -10
- package/dist/plugins/approval-ask-store-sql.js +2 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +16 -3
- package/dist/plugins/checkpoint-store-sql.js +7 -9
- package/dist/plugins/image-bake-store-sql.js +85 -13
- package/dist/plugins/image-index-sql.js +75 -12
- package/dist/plugins/local-checkpoint-store.d.ts +23 -2
- package/dist/plugins/local-checkpoint-store.js +40 -33
- package/dist/plugins/local-session-store.d.ts +14 -0
- package/dist/plugins/local-session-store.js +12 -7
- package/dist/plugins/local-sidecar-quarantine.d.ts +10 -0
- package/dist/plugins/local-sidecar-quarantine.js +15 -0
- package/dist/plugins/local-task-attachment-store.d.ts +1 -1
- package/dist/plugins/local-task-attachment-store.js +5 -2
- package/dist/plugins/mailbox-store-sql.d.ts +6 -3
- package/dist/plugins/mailbox-store-sql.js +45 -43
- package/dist/plugins/memory-embedder-fingerprint.js +8 -12
- package/dist/plugins/memory-engine-pg.js +34 -16
- package/dist/plugins/memory-engine-tidb.js +19 -12
- package/dist/plugins/memory-engine-vector-util.d.ts +26 -0
- package/dist/plugins/memory-engine-vector-util.js +3 -0
- package/dist/plugins/memory-sync-store-pg.d.ts +6 -0
- package/dist/plugins/memory-sync-store-pg.js +14 -10
- package/dist/plugins/memory-sync-store-tidb.js +7 -11
- package/dist/plugins/pg-session-storage.js +26 -28
- package/dist/plugins/session-event-payload.d.ts +37 -0
- package/dist/plugins/session-event-payload.js +20 -0
- package/dist/plugins/sql-json-column.d.ts +124 -22
- package/dist/plugins/sql-json-column.js +22 -1
- package/dist/plugins/sql-row-helpers.d.ts +10 -16
- package/dist/plugins/sql-row-helpers.js +0 -29
- package/dist/plugins/store-backend.js +10 -12
- package/dist/plugins/store-contracts.d.ts +4 -6
- package/dist/plugins/store-contracts.js +0 -60
- package/dist/plugins/task-attachment-store.d.ts +21 -12
- package/dist/plugins/task-attachment-store.js +12 -0
- package/dist/plugins/tidb-session-storage.d.ts +2 -2
- package/dist/plugins/tidb-session-storage.js +12 -11
- package/dist/plugins/tidb-session-store.d.ts +2 -2
- package/dist/plugins/tidb-session-store.js +20 -22
- package/dist/plugins/usage-window-store-sql.d.ts +9 -1
- package/dist/plugins/usage-window-store-sql.js +23 -9
- package/dist/plugins/workflow-journal-store-sql.js +16 -4
- package/dist/run-local.js +2 -10
- package/dist/usage-analytics.d.ts +14 -1
- package/dist/usage-analytics.js +11 -17
- package/package.json +2 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { uuidv7 } from "@sema-agent/core";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { pgSanitizeText, pgHasUnstorable } from "./pg-safe-json.js";
|
|
4
|
-
import {
|
|
4
|
+
import { toIso as iso, normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
|
|
5
5
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
6
|
-
import {
|
|
6
|
+
import { decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readNonNullJsonValueColumn, readStringArrayColumn, } from "./sql-json-column.js";
|
|
7
|
+
import {} from "./store-contracts.js";
|
|
7
8
|
import { isDupKeyError } from "./sql-errors.js";
|
|
8
9
|
import { ensureIndexes } from "./ensure-index.js";
|
|
9
10
|
export const PG_IMAGE_BAKE_SCHEMA = [
|
|
@@ -61,6 +62,77 @@ export const PG_IMAGE_BAKE_SCHEMA = [
|
|
|
61
62
|
PRIMARY KEY (pool)
|
|
62
63
|
)`,
|
|
63
64
|
];
|
|
65
|
+
function bakeSelectCols(dialect) {
|
|
66
|
+
return [
|
|
67
|
+
"bake_id",
|
|
68
|
+
"profile",
|
|
69
|
+
jsonColumnText(dialect, "bands"),
|
|
70
|
+
"base_ref",
|
|
71
|
+
"push",
|
|
72
|
+
"dry_run",
|
|
73
|
+
"logs",
|
|
74
|
+
jsonColumnText(dialect, "argv"),
|
|
75
|
+
"status",
|
|
76
|
+
"state",
|
|
77
|
+
"digest",
|
|
78
|
+
"repo",
|
|
79
|
+
"ref",
|
|
80
|
+
"index_id",
|
|
81
|
+
"exit_code",
|
|
82
|
+
"error",
|
|
83
|
+
"error_code",
|
|
84
|
+
"manifest_sha",
|
|
85
|
+
"tag",
|
|
86
|
+
"idem_key",
|
|
87
|
+
"ingest_secret",
|
|
88
|
+
"runner_id",
|
|
89
|
+
"lease_until",
|
|
90
|
+
"cancel_requested",
|
|
91
|
+
"requested_by",
|
|
92
|
+
"created_at",
|
|
93
|
+
"updated_at",
|
|
94
|
+
].join(", ");
|
|
95
|
+
}
|
|
96
|
+
function mapRow(r) {
|
|
97
|
+
return {
|
|
98
|
+
bakeId: r.bake_id,
|
|
99
|
+
profile: r.profile,
|
|
100
|
+
bands: decodeNullableJsonColumn(r.bands, readStringArrayColumn, bakeWhere("bands", r.bake_id)) ?? null,
|
|
101
|
+
baseRef: r.base_ref,
|
|
102
|
+
push: !!r.push,
|
|
103
|
+
dryRun: !!r.dry_run,
|
|
104
|
+
logs: !!r.logs,
|
|
105
|
+
argv: decodeJsonColumn(r.argv, readStringArrayColumn, bakeWhere("argv", r.bake_id)),
|
|
106
|
+
status: r.status,
|
|
107
|
+
state: r.state ?? null,
|
|
108
|
+
digest: r.digest,
|
|
109
|
+
repo: r.repo,
|
|
110
|
+
ref: r.ref,
|
|
111
|
+
indexId: r.index_id,
|
|
112
|
+
exitCode: r.exit_code === null ? null : Number(r.exit_code),
|
|
113
|
+
error: r.error,
|
|
114
|
+
errorCode: r.error_code ?? null,
|
|
115
|
+
manifestSha: r.manifest_sha,
|
|
116
|
+
tag: r.tag,
|
|
117
|
+
idemKey: r.idem_key,
|
|
118
|
+
ingestSecret: r.ingest_secret,
|
|
119
|
+
runnerId: r.runner_id,
|
|
120
|
+
leaseUntil: isoOrNull(r.lease_until),
|
|
121
|
+
cancelRequested: !!r.cancel_requested,
|
|
122
|
+
requestedBy: r.requested_by,
|
|
123
|
+
createdAt: iso(r.created_at),
|
|
124
|
+
updatedAt: iso(r.updated_at),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const bakeWhere = (column, bakeId) => `image_bake.${column} for bake ${bakeId}`;
|
|
128
|
+
function refusingBakeRow(read) {
|
|
129
|
+
try {
|
|
130
|
+
return read();
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
throw foldJsonColumn(e, (m) => new Error(`image-bake-store: ${m} — refusing rather than serve a bake row nobody can read`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
64
136
|
export const IMAGE_BAKE_INDEXES = [
|
|
65
137
|
{ table: "image_bake", name: "idx_image_bake_status", columns: ["status"] },
|
|
66
138
|
{ table: "image_bake", name: "idx_image_bake_created", columns: ["created_at"] },
|
|
@@ -183,23 +255,23 @@ export class SqlImageBake {
|
|
|
183
255
|
return { created: true, bake };
|
|
184
256
|
}
|
|
185
257
|
async getBake(bakeId) {
|
|
186
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
187
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
258
|
+
const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE bake_id = ? LIMIT 1`, `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE bake_id = $1 LIMIT 1`), [bakeId]);
|
|
259
|
+
return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
|
|
188
260
|
}
|
|
189
261
|
async getBakeByIdem(idemKey) {
|
|
190
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
191
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
262
|
+
const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE idem_key = ? LIMIT 1`, `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE idem_key = $1 LIMIT 1`), [idemKey]);
|
|
263
|
+
return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
|
|
192
264
|
}
|
|
193
265
|
async findActiveByArgv(argv) {
|
|
194
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
195
|
-
"ORDER BY created_at DESC LIMIT 1", `SELECT ${
|
|
266
|
+
const { rows } = await this.db.query(this.q(`SELECT ${bakeSelectCols("tidb")} FROM image_bake WHERE status IN ('queued','running') AND argv = CAST(? AS JSON) ` +
|
|
267
|
+
"ORDER BY created_at DESC LIMIT 1", `SELECT ${bakeSelectCols("pg")} FROM image_bake WHERE status IN ('queued','running') AND argv = $1::jsonb ` +
|
|
196
268
|
"ORDER BY created_at DESC LIMIT 1"), [JSON.stringify(argv)]);
|
|
197
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
269
|
+
return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
|
|
198
270
|
}
|
|
199
271
|
async findActiveAny() {
|
|
200
|
-
const { rows } = await this.db.query(`SELECT ${
|
|
272
|
+
const { rows } = await this.db.query(`SELECT ${bakeSelectCols(this.db.dialect)} FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 ` +
|
|
201
273
|
"ORDER BY created_at ASC LIMIT 1", []);
|
|
202
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
274
|
+
return rows[0] ? refusingBakeRow(() => mapRow(rows[0])) : null;
|
|
203
275
|
}
|
|
204
276
|
async findNextQueuedId() {
|
|
205
277
|
const { rows } = await this.db.query("SELECT bake_id FROM image_bake WHERE status = 'queued' ORDER BY created_at ASC LIMIT 1", []);
|
|
@@ -313,13 +385,13 @@ export class SqlImageBake {
|
|
|
313
385
|
return m == null ? 0 : Number(m);
|
|
314
386
|
}
|
|
315
387
|
async getEvents(bakeId, afterSeq) {
|
|
316
|
-
const { rows } = await this.db.query(this.q(
|
|
388
|
+
const { rows } = await this.db.query(this.q(`SELECT seq, line_ord, kind, level, ${jsonColumnText("tidb", "data")}, ts FROM image_bake_event WHERE bake_id = ? AND seq > ? ORDER BY seq ASC`, `SELECT seq, line_ord, kind, level, ${jsonColumnText("pg", "data")}, ts FROM image_bake_event WHERE bake_id = $1 AND seq > $2 ORDER BY seq ASC`), [bakeId, afterSeq]);
|
|
317
389
|
return rows.map((r) => ({
|
|
318
390
|
seq: Number(r.seq),
|
|
319
391
|
lineOrd: r.line_ord === null ? null : Number(r.line_ord),
|
|
320
392
|
kind: String(r.kind),
|
|
321
393
|
level: r.level ?? null,
|
|
322
|
-
data:
|
|
394
|
+
data: decodeNullableJsonColumn(r.data, readNonNullJsonValueColumn, `image_bake_event.data for bake ${bakeId} seq ${String(r.seq)}`) ?? null,
|
|
323
395
|
ts: iso(r.ts),
|
|
324
396
|
}));
|
|
325
397
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { pgSafeJsonStringify, pgSanitizeText, pgHasUnstorable, PgUnstorableError } from "./pg-safe-json.js";
|
|
3
|
-
import {
|
|
3
|
+
import {} from "./store-contracts.js";
|
|
4
4
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
5
|
+
import { createObjectColumnReader, decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readJsonArrayColumn, readJsonObjectColumn } from "./sql-json-column.js";
|
|
6
|
+
import { normalizeIsoOrNull as isoOrNull } from "./sql-row-helpers.js";
|
|
5
7
|
import { ensureIndexes } from "./ensure-index.js";
|
|
6
8
|
export const PG_IMAGE_INDEX_SCHEMA = [
|
|
7
9
|
`CREATE TABLE IF NOT EXISTS sandbox_image_index (
|
|
@@ -29,6 +31,67 @@ export const PG_IMAGE_INDEX_SCHEMA = [
|
|
|
29
31
|
PRIMARY KEY (id)
|
|
30
32
|
)`,
|
|
31
33
|
];
|
|
34
|
+
function imageSelectCols(dialect) {
|
|
35
|
+
return [
|
|
36
|
+
"id",
|
|
37
|
+
"profile",
|
|
38
|
+
jsonColumnText(dialect, "bands"),
|
|
39
|
+
"repo",
|
|
40
|
+
"tag",
|
|
41
|
+
"digest",
|
|
42
|
+
jsonColumnText(dialect, "toolchain_versions"),
|
|
43
|
+
jsonColumnText(dialect, "capabilities"),
|
|
44
|
+
jsonColumnText(dialect, "pod_contract"),
|
|
45
|
+
"size_bytes",
|
|
46
|
+
"status",
|
|
47
|
+
"visibility",
|
|
48
|
+
"tenant_id",
|
|
49
|
+
"manifest_sha",
|
|
50
|
+
"recipe_git_sha",
|
|
51
|
+
"generator_version",
|
|
52
|
+
"build_date",
|
|
53
|
+
"supersedes",
|
|
54
|
+
"signed",
|
|
55
|
+
"created_at",
|
|
56
|
+
"updated_at",
|
|
57
|
+
].join(", ");
|
|
58
|
+
}
|
|
59
|
+
const readCapabilitiesColumn = createObjectColumnReader();
|
|
60
|
+
const readPodContractColumn = createObjectColumnReader();
|
|
61
|
+
function mapRow(r) {
|
|
62
|
+
const at = (column) => `sandbox_image_index.${column} for image ${r.id}`;
|
|
63
|
+
return {
|
|
64
|
+
id: r.id,
|
|
65
|
+
profile: r.profile,
|
|
66
|
+
bands: decodeNullableJsonColumn(r.bands, readJsonArrayColumn, at("bands")) ?? [],
|
|
67
|
+
repo: r.repo,
|
|
68
|
+
tag: r.tag,
|
|
69
|
+
digest: r.digest,
|
|
70
|
+
toolchainVersions: decodeNullableJsonColumn(r.toolchain_versions, readJsonObjectColumn, at("toolchain_versions")) ?? {},
|
|
71
|
+
capabilities: decodeNullableJsonColumn(r.capabilities, readCapabilitiesColumn, at("capabilities")) ?? {},
|
|
72
|
+
podContract: decodeNullableJsonColumn(r.pod_contract, readPodContractColumn, at("pod_contract")) ?? {},
|
|
73
|
+
sizeBytes: r.size_bytes === null ? null : Number(r.size_bytes),
|
|
74
|
+
status: r.status,
|
|
75
|
+
visibility: r.visibility,
|
|
76
|
+
tenantId: r.tenant_id,
|
|
77
|
+
manifestSha: r.manifest_sha,
|
|
78
|
+
recipeGitSha: r.recipe_git_sha,
|
|
79
|
+
generatorVersion: r.generator_version,
|
|
80
|
+
buildDate: isoOrNull(r.build_date),
|
|
81
|
+
supersedes: r.supersedes,
|
|
82
|
+
signed: !!r.signed,
|
|
83
|
+
createdAt: isoOrNull(r.created_at) ?? new Date(0).toISOString(),
|
|
84
|
+
updatedAt: isoOrNull(r.updated_at) ?? new Date(0).toISOString(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function refusingImageRow(read) {
|
|
88
|
+
try {
|
|
89
|
+
return read();
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
throw foldJsonColumn(e, (m) => new Error(`image-index: ${m} — refusing rather than serve an image row nobody can read`));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
32
95
|
export const IMAGE_INDEX_INDEXES = [
|
|
33
96
|
{ table: "sandbox_image_index", name: "uq_repo_digest", columns: ["repo", "digest"], unique: true },
|
|
34
97
|
{ table: "sandbox_image_index", name: "idx_sandbox_image_index_profile_status", columns: ["profile", "status", "visibility"] },
|
|
@@ -199,26 +262,26 @@ export class SqlImageIndex {
|
|
|
199
262
|
return id;
|
|
200
263
|
}
|
|
201
264
|
async getById(id) {
|
|
202
|
-
const { rows } = await this.db.query(this.q(
|
|
203
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
265
|
+
const { rows } = await this.db.query(this.q(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE id=? LIMIT 1`, `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE id = $1 LIMIT 1`), [id]);
|
|
266
|
+
return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
|
|
204
267
|
}
|
|
205
268
|
async getByDigest(digest) {
|
|
206
|
-
const { rows } = await this.db.query(this.q(
|
|
207
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
269
|
+
const { rows } = await this.db.query(this.q(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE digest=? ORDER BY updated_at DESC LIMIT 1`, `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE digest = $1 ORDER BY updated_at DESC LIMIT 1`), [digest]);
|
|
270
|
+
return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
|
|
208
271
|
}
|
|
209
272
|
async latestPublished(profile, viewer) {
|
|
210
273
|
if (this.db.dialect === "tidb") {
|
|
211
274
|
const vis = visibilityClauseTidb(viewer);
|
|
212
|
-
const { rows } = await this.db.query(`SELECT
|
|
275
|
+
const { rows } = await this.db.query(`SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index
|
|
213
276
|
WHERE profile=? AND status='published' AND ${vis.sql}
|
|
214
277
|
ORDER BY (build_date IS NULL), build_date DESC, created_at DESC LIMIT 1`, [profile, ...vis.params]);
|
|
215
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
278
|
+
return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
|
|
216
279
|
}
|
|
217
280
|
const vis = visibilityClausePg(viewer, 2);
|
|
218
|
-
const { rows } = await this.db.query(`SELECT
|
|
281
|
+
const { rows } = await this.db.query(`SELECT ${imageSelectCols("pg")} FROM sandbox_image_index
|
|
219
282
|
WHERE profile = $1 AND status = 'published' AND ${vis.sql}
|
|
220
283
|
ORDER BY build_date DESC NULLS LAST, created_at DESC LIMIT 1`, [profile, ...vis.params]);
|
|
221
|
-
return rows[0] ? mapRow(rows[0]) : null;
|
|
284
|
+
return rows[0] ? refusingImageRow(() => mapRow(rows[0])) : null;
|
|
222
285
|
}
|
|
223
286
|
async list(filter) {
|
|
224
287
|
const limit = Math.min(Math.max(1, filter.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
@@ -246,7 +309,7 @@ export class SqlImageIndex {
|
|
|
246
309
|
params.push(new Date(c.createdAt), new Date(c.createdAt), c.id);
|
|
247
310
|
}
|
|
248
311
|
}
|
|
249
|
-
const sql = `SELECT
|
|
312
|
+
const sql = `SELECT ${imageSelectCols("tidb")} FROM sandbox_image_index WHERE ${where.join(" AND ")} ` +
|
|
250
313
|
`ORDER BY created_at DESC, id DESC LIMIT ?`;
|
|
251
314
|
const { rows } = await this.db.query(sql, [...params, limit + 1]);
|
|
252
315
|
rawRows = rows;
|
|
@@ -276,13 +339,13 @@ export class SqlImageIndex {
|
|
|
276
339
|
idx += 3;
|
|
277
340
|
}
|
|
278
341
|
}
|
|
279
|
-
const sql = `SELECT
|
|
342
|
+
const sql = `SELECT ${imageSelectCols("pg")} FROM sandbox_image_index WHERE ${where.join(" AND ")} ` +
|
|
280
343
|
`ORDER BY created_at DESC, id DESC LIMIT $${idx}`;
|
|
281
344
|
params.push(limit + 1);
|
|
282
345
|
const { rows } = await this.db.query(sql, params);
|
|
283
346
|
rawRows = rows;
|
|
284
347
|
}
|
|
285
|
-
let entries = rawRows.map(mapRow);
|
|
348
|
+
let entries = refusingImageRow(() => rawRows.map(mapRow));
|
|
286
349
|
let nextCursor = null;
|
|
287
350
|
if (entries.length > limit) {
|
|
288
351
|
entries = entries.slice(0, limit);
|
|
@@ -183,8 +183,29 @@ export declare class LocalCheckpointStore {
|
|
|
183
183
|
sessionId: string;
|
|
184
184
|
scope: string;
|
|
185
185
|
}>>;
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
/**
|
|
187
|
+
* 🔴 **写腿的形闸与 SQL 孪生逐字同一条**(codex 对抗复审 r1 [medium],验真后按类修):TS 的 `object` 说不出
|
|
188
|
+
* 「序列化之后是一只 JSON 对象」—— 数组 / `Date` / `toJSON()` 返回标量或 `null` 的对象全都通得过类型面,
|
|
189
|
+
* 各自写出一份读腿判坏的字节。而这一写是**无条件覆盖**,所以它会「成功」地把一份读得回来的耐久 ctx 换成
|
|
190
|
+
* 读不回来的 —— 调用方拿 2xx,下一次续跑永久 409。⇒ 闸在落盘之前,盘上那一份原样不动。
|
|
191
|
+
*
|
|
192
|
+
* 🔴 **闸判的必须是「将要落盘的那一串字节」本身**(codex r2 [medium],验真后修):初版判的是
|
|
193
|
+
* `JSON.stringify(ctx)`,而真正落盘的是 `JSON.stringify(rec)` —— `toJSON(key)` 拿得到**键名**,所以
|
|
194
|
+
* 一个 `toJSON(k){ return k === "ctx" ? null : {…} }` 的对象验的是 `{…}`、写的是 `{"ctx":null}`
|
|
195
|
+
* (实测两串逐字不同)。有状态的序列化器同理。⇒ 整条记录**只序列化一次**,闸判那一串,写也写那一串。
|
|
196
|
+
*/
|
|
197
|
+
putCtx(sessionId: string, ctx: object): Promise<void>;
|
|
198
|
+
/**
|
|
199
|
+
* 🔴 **坏体 ⇒ 拒,绝不读成缺席**(S-577,与 SQL 孪生同一条判据)。修前这里是
|
|
200
|
+
* `catch { return null }` + `(rec.ctx as T) ?? null`,于是一个撕裂 / 手改 / 权限读不动的 ctx 文件与
|
|
201
|
+
* 「这条会话根本没有 ctx」在消费点上**逐字同答**(409 `resume context missing`)—— 运维据此去「重新
|
|
202
|
+
* 提交一个任务」,而真因是盘上那一格坏了。三个店同向:不是「这一行不在」,是「这一行读不出来」。
|
|
203
|
+
*
|
|
204
|
+
* `ctx` 的形判据同 SQL 孪生:**一只 JSON 对象** —— 而且是**同一只读者**({@link createCtxRecordReader}
|
|
205
|
+
* 里合成的 {@link createObjectColumnReader}),不是「同一个意思的两份判据」。写腿 `putCtx(…, ctx: object)`
|
|
206
|
+
* 只收对象,读腿只认对象,两条腿一个类型。
|
|
207
|
+
*/
|
|
208
|
+
getCtx<T extends object = object>(sessionId: string): Promise<T | null>;
|
|
188
209
|
/** GC ctx files whose lifecycle is over — same three guards as the SQL twins (stale ∧ no pending ∧ no active run). */
|
|
189
210
|
reapCtx(cutoff: number): Promise<number>;
|
|
190
211
|
/**
|
|
@@ -1,17 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { ASK_USER_QUESTION_TOOL_NAME, contentKindOf } from "../approval-content-kind.js";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { existsSync, readFileSync, readdirSync,
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { isApprovalGateKind } from "../tool-approval.js";
|
|
6
7
|
import { FileCheckpointStore, atomicWriteFile, executionVerdict, sanitizePathComponent, } from "@sema-agent/core";
|
|
7
8
|
import { approvalDecisionOfWinner, approvalPayloadFingerprint, boundedRuleOffers, boundedToolInput, pendingActionToolCallId, pendingRowPrecedes, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
|
|
9
|
+
import { quarantineUnreadableSidecar } from "./local-sidecar-quarantine.js";
|
|
10
|
+
import { createZodColumnReader, decodeJsonColumn, readStringArrayColumn } from "./sql-json-column.js";
|
|
8
11
|
import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
|
|
12
|
+
import { assertJsonColumnWritable, createObjectColumnReader } from "./sql-json-column.js";
|
|
9
13
|
function terminalAtOf(cp) {
|
|
10
14
|
return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
|
|
11
15
|
}
|
|
12
16
|
function ctxFileOf(sessionId) {
|
|
13
17
|
return `${sanitizePathComponent(sessionId).slice(0, 48)}-${createHash("sha256").update(sessionId).digest("hex").slice(0, 12)}.json`;
|
|
14
18
|
}
|
|
19
|
+
const CtxRecordSchema = z.object({
|
|
20
|
+
sessionId: z.string(),
|
|
21
|
+
updatedAt: z.number(),
|
|
22
|
+
ctx: z.unknown(),
|
|
23
|
+
});
|
|
24
|
+
const readCtxRecord = createZodColumnReader(CtxRecordSchema);
|
|
25
|
+
const readScopeRegistry = readStringArrayColumn;
|
|
26
|
+
const readSessionTokenIndex = createZodColumnReader(z.record(z.string(), z.array(z.string())));
|
|
27
|
+
function createCtxRecordReader() {
|
|
28
|
+
const readPayload = createObjectColumnReader();
|
|
29
|
+
return (body) => {
|
|
30
|
+
const envelope = readCtxRecord(body);
|
|
31
|
+
if (envelope.problem !== undefined)
|
|
32
|
+
return { problem: envelope.problem };
|
|
33
|
+
const payload = readPayload(envelope.value.ctx);
|
|
34
|
+
if (payload.problem !== undefined)
|
|
35
|
+
return { problem: `its \`ctx\` member is not a JSON object (${payload.problem})` };
|
|
36
|
+
return { value: { ...envelope.value, ctx: payload.value } };
|
|
37
|
+
};
|
|
38
|
+
}
|
|
15
39
|
export class LocalCheckpointStore {
|
|
16
40
|
retention = UNMANAGED_RETENTION;
|
|
17
41
|
durability = "durable";
|
|
@@ -37,37 +61,23 @@ export class LocalCheckpointStore {
|
|
|
37
61
|
mkdirSync(this.tmpDir, { recursive: true });
|
|
38
62
|
if (existsSync(this.scopesPath)) {
|
|
39
63
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
for (const s of arr)
|
|
43
|
-
if (typeof s === "string")
|
|
44
|
-
this.scopes.add(s);
|
|
64
|
+
for (const s of decodeJsonColumn(readFileSync(this.scopesPath, "utf8"), readScopeRegistry, "checkpoint-scopes.json (the local scope registry)"))
|
|
65
|
+
this.scopes.add(s);
|
|
45
66
|
}
|
|
46
67
|
catch (e) {
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
renameSync(this.scopesPath, quarantine);
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
}
|
|
68
|
+
quarantineUnreadableSidecar(this.scopesPath, "LocalCheckpointStore scope registry", e instanceof Error ? e.message : String(e));
|
|
53
69
|
this.scopeRegistryCorrupt = true;
|
|
54
|
-
console.warn(
|
|
55
|
-
`will rebuild the scope set from the data plane on first read (core listScopes); ` +
|
|
56
|
-
`file quarantined at ${quarantine}`);
|
|
70
|
+
console.warn("LocalCheckpointStore: will rebuild the scope set from the data plane on first read (core listScopes) — enumeration is EMPTY until then");
|
|
57
71
|
}
|
|
58
72
|
}
|
|
59
73
|
if (existsSync(this.sessionTokensPath)) {
|
|
60
74
|
try {
|
|
61
|
-
const rec =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!Array.isArray(toks))
|
|
65
|
-
continue;
|
|
66
|
-
this.sessionTokens.set(sid, new Set(toks.filter((t) => typeof t === "string")));
|
|
67
|
-
}
|
|
68
|
-
}
|
|
75
|
+
const rec = decodeJsonColumn(readFileSync(this.sessionTokensPath, "utf8"), readSessionTokenIndex, "checkpoint-session-tokens.json (the local session→tokens index)");
|
|
76
|
+
for (const [sid, toks] of Object.entries(rec))
|
|
77
|
+
this.sessionTokens.set(sid, new Set(toks));
|
|
69
78
|
}
|
|
70
|
-
catch {
|
|
79
|
+
catch (e) {
|
|
80
|
+
quarantineUnreadableSidecar(this.sessionTokensPath, "LocalCheckpointStore session→tokens index", e instanceof Error ? e.message : String(e));
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
}
|
|
@@ -298,19 +308,16 @@ export class LocalCheckpointStore {
|
|
|
298
308
|
}
|
|
299
309
|
async putCtx(sessionId, ctx) {
|
|
300
310
|
const rec = { sessionId, updatedAt: Date.now(), ctx };
|
|
301
|
-
|
|
311
|
+
const text = JSON.stringify(rec);
|
|
312
|
+
assertJsonColumnWritable(text, createCtxRecordReader(), `checkpoint ctx for session ${JSON.stringify(sessionId)} (local write leg)`);
|
|
313
|
+
atomicWriteFile(this.tmpDir, join(this.ctxDir, ctxFileOf(sessionId)), text);
|
|
302
314
|
}
|
|
303
315
|
async getCtx(sessionId) {
|
|
304
316
|
const p = join(this.ctxDir, ctxFileOf(sessionId));
|
|
305
317
|
if (!existsSync(p))
|
|
306
318
|
return null;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
return rec.sessionId === sessionId ? (rec.ctx ?? null) : null;
|
|
310
|
-
}
|
|
311
|
-
catch {
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
319
|
+
const rec = decodeJsonColumn(readFileSync(p, "utf8"), createCtxRecordReader(), `checkpoint-ctx ${ctxFileOf(sessionId)} for session ${JSON.stringify(sessionId)}`);
|
|
320
|
+
return rec.sessionId === sessionId ? rec.ctx : null;
|
|
314
321
|
}
|
|
315
322
|
async reapCtx(cutoff) {
|
|
316
323
|
let n = 0;
|
|
@@ -320,7 +327,7 @@ export class LocalCheckpointStore {
|
|
|
320
327
|
const p = join(this.ctxDir, f);
|
|
321
328
|
let rec;
|
|
322
329
|
try {
|
|
323
|
-
rec =
|
|
330
|
+
rec = decodeJsonColumn(readFileSync(p, "utf8"), readCtxRecord, `checkpoint-ctx ${f} (reap scan)`);
|
|
324
331
|
}
|
|
325
332
|
catch {
|
|
326
333
|
continue;
|
|
@@ -107,6 +107,20 @@ export declare class LocalSessionStore implements SessionStore {
|
|
|
107
107
|
private doFlush;
|
|
108
108
|
/** Throttled best-effort warn for a sidecar I/O failure (bug D): never throws, at most once per ~minute so a
|
|
109
109
|
* persistently-failing disk doesn't spam the log on every debounced flush. */
|
|
110
|
+
/**
|
|
111
|
+
* 读不出一份边档时的**唯一**处置(两只 sidecar 共用)——先**留证**再降级。
|
|
112
|
+
*
|
|
113
|
+
* 🔴 为什么留证是必需的而不是洁癖(codex 对抗复审一轮 [medium],亲验为真;红先在
|
|
114
|
+
* `test/local-store-bad-body.test.ts`):两只边档都是**整档原子替换**的写法,所以「读不出 ⇒ 空表 +
|
|
115
|
+
* 继续服务」之后,**下一次写就把原档盖掉了**。对一份整份是垃圾的体无所谓;对一份只有某一格值型
|
|
116
|
+
* 不对的**混合体**,那等于把一次局部的、可修的损坏放大成一次全量的、不可逆的丢失。判据不变
|
|
117
|
+
* (半份值仍然不许端出去),改的是拒的那一刻。
|
|
118
|
+
*
|
|
119
|
+
* 两条 warn 各说一件事:{@link quarantineUnreadableSidecar} 说「字节挪到哪了」(每次都说,因为它稀有
|
|
120
|
+
* 且不可重现),{@link warnSidecar} 说「这条腿降级了」(节流,因为写失败可能每拍都发生)。
|
|
121
|
+
* ENOENT(首次启动没有这份档)在调用点就被排除,压根到不了这里。
|
|
122
|
+
*/
|
|
123
|
+
private refuseSidecar;
|
|
110
124
|
private warnSidecar;
|
|
111
125
|
acquire(sessionId?: string, opts?: {
|
|
112
126
|
requireExisting?: boolean;
|
|
@@ -2,6 +2,8 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { SessionError, validateEntriesForImport } from "@sema-agent/core";
|
|
4
4
|
import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
|
|
5
|
+
import { quarantineUnreadableSidecar } from "./local-sidecar-quarantine.js";
|
|
6
|
+
import { decodeJsonColumn, readStringRecordColumn } from "./sql-json-column.js";
|
|
5
7
|
import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
|
|
6
8
|
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
|
|
7
9
|
const ownerEq = (a, b) => (a ?? null) === (b ?? null);
|
|
@@ -62,15 +64,15 @@ export class LocalSessionStore {
|
|
|
62
64
|
const path = this.titleSidecarPath;
|
|
63
65
|
st.loaded = (async () => {
|
|
64
66
|
try {
|
|
65
|
-
const parsed =
|
|
67
|
+
const parsed = decodeJsonColumn(await readFile(path, "utf8"), readStringRecordColumn, "session-titles.json (the local title sidecar)");
|
|
66
68
|
for (const [id, t] of Object.entries(parsed)) {
|
|
67
|
-
if (
|
|
69
|
+
if (!st.titles.has(id))
|
|
68
70
|
st.titles.set(id, t);
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
catch (e) {
|
|
72
74
|
if (e?.code !== "ENOENT")
|
|
73
|
-
this.
|
|
75
|
+
this.refuseSidecar(path, "LocalSessionStore title sidecar", e);
|
|
74
76
|
}
|
|
75
77
|
})();
|
|
76
78
|
}
|
|
@@ -113,16 +115,15 @@ export class LocalSessionStore {
|
|
|
113
115
|
const path = this.sidecarPath;
|
|
114
116
|
this.loaded = (async () => {
|
|
115
117
|
try {
|
|
116
|
-
const
|
|
117
|
-
const parsed = JSON.parse(raw);
|
|
118
|
+
const parsed = decodeJsonColumn(await readFile(path, "utf8"), readStringRecordColumn, "session-last-activity.json (the local recency sidecar)");
|
|
118
119
|
for (const [id, ts] of Object.entries(parsed)) {
|
|
119
|
-
if (
|
|
120
|
+
if (!this.lastActivity.has(id))
|
|
120
121
|
this.lastActivity.set(id, ts);
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
catch (e) {
|
|
124
125
|
if (e?.code !== "ENOENT")
|
|
125
|
-
this.
|
|
126
|
+
this.refuseSidecar(path, "LocalSessionStore lastActivity sidecar", e);
|
|
126
127
|
}
|
|
127
128
|
})();
|
|
128
129
|
}
|
|
@@ -164,6 +165,10 @@ export class LocalSessionStore {
|
|
|
164
165
|
if (this.dirty)
|
|
165
166
|
await this.doFlush();
|
|
166
167
|
}
|
|
168
|
+
refuseSidecar(path, what, e) {
|
|
169
|
+
quarantineUnreadableSidecar(path, what, e instanceof Error ? e.message : String(e));
|
|
170
|
+
this.warnSidecar("read", e);
|
|
171
|
+
}
|
|
167
172
|
warnSidecar(op, e) {
|
|
168
173
|
const now = Date.now();
|
|
169
174
|
if (now - this.lastWarnAt < 60_000)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把一份读不出形的档挪开并**大声说**。永不抛(它挂在降级臂上,不许把降级变成故障)。
|
|
3
|
+
*
|
|
4
|
+
* @param path 原档路径。
|
|
5
|
+
* @param what 给人读的一句话:这是什么档、读不出会怎样(进 warn 文案)。
|
|
6
|
+
* @param reason 判形失败的原因(属主的错误文案 / 异常消息)。
|
|
7
|
+
* @returns 隔离档的路径;挪不动时 `undefined`(此时 warn 里已说清)。
|
|
8
|
+
*/
|
|
9
|
+
export declare function quarantineUnreadableSidecar(path: string, what: string, reason: string): string | undefined;
|
|
10
|
+
//# sourceMappingURL=local-sidecar-quarantine.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { renameSync } from "node:fs";
|
|
2
|
+
export function quarantineUnreadableSidecar(path, what, reason) {
|
|
3
|
+
const target = `${path}.corrupt-${Date.now()}-${process.pid}`;
|
|
4
|
+
try {
|
|
5
|
+
renameSync(path, target);
|
|
6
|
+
}
|
|
7
|
+
catch (e) {
|
|
8
|
+
console.warn(`${what}: ${path} is unreadable (${reason}) and could NOT be quarantined (${e instanceof Error ? e.message : String(e)}) — ` +
|
|
9
|
+
`the bytes are still at that path; a later write to it may replace them`);
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
console.warn(`${what}: ${path} is unreadable (${reason}) — quarantined at ${target} so the next write cannot replace it`);
|
|
13
|
+
return target;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=local-sidecar-quarantine.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type TaskAttachmentStore, type TaskAttachmentRecord } from "./task-attachment-store.js";
|
|
2
2
|
export declare class LocalTaskAttachmentStore implements TaskAttachmentStore {
|
|
3
3
|
private readonly dir;
|
|
4
4
|
private metas;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { promises as fsp } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { TaskAttachmentRecordSchema } from "./task-attachment-store.js";
|
|
4
|
+
import { createZodColumnReader, decodeJsonColumn } from "./sql-json-column.js";
|
|
3
5
|
const ID_RE = /^[0-9a-f-]{16,64}$/i;
|
|
6
|
+
const readAttachmentMeta = createZodColumnReader(TaskAttachmentRecordSchema);
|
|
4
7
|
export class LocalTaskAttachmentStore {
|
|
5
8
|
dir;
|
|
6
9
|
metas;
|
|
@@ -19,8 +22,8 @@ export class LocalTaskAttachmentStore {
|
|
|
19
22
|
if (!ID_RE.test(id))
|
|
20
23
|
continue;
|
|
21
24
|
try {
|
|
22
|
-
const meta =
|
|
23
|
-
if (meta
|
|
25
|
+
const meta = decodeJsonColumn(await fsp.readFile(join(this.dir, f), "utf8"), readAttachmentMeta, `attachments/${f} (task attachment meta)`);
|
|
26
|
+
if (meta.id === id)
|
|
24
27
|
m.set(id, meta);
|
|
25
28
|
}
|
|
26
29
|
catch {
|
|
@@ -55,9 +55,12 @@ export declare const MAILBOX_MSG_TABLE = "mailbox_message";
|
|
|
55
55
|
* —— `mailbox` 行是**盒**事实(next_seq / lease),墓碑是**收件人**事实,两者的生命周期本来就不同
|
|
56
56
|
* (`drop` 同时终结两者 —— 墓碑随箱亡,理由全文见 `mailbox-recipient-lifecycle.ts` 模块头)。 */
|
|
57
57
|
export declare const MAILBOX_TOMBSTONE_TABLE = "mailbox_tombstone";
|
|
58
|
-
/** 店侧日志座(可选;boot 注入 `Logger`)。7.58.0 合并重扫 [medium]
|
|
59
|
-
* claimLease 调用方(peer-session-drain)吞掉该错,店不记这一笔就成了「静默永久卡箱」。抛之前记
|
|
60
|
-
* (scope/handle/seq/code),运维按 seq 修行。
|
|
58
|
+
/** 店侧日志座(可选;boot 注入 `Logger`)。7.58.0 合并重扫 [medium]:坏行的 fail-closed 抛必须**响亮**——core 唯一的
|
|
59
|
+
* claimLease 调用方(peer-session-drain)吞掉该错,店不记这一笔就成了「静默永久卡箱」。抛之前记
|
|
60
|
+
* `mailbox_message_column_corrupt`(scope/handle/seq/column/code),运维按 seq 修行。
|
|
61
|
+
* 🔴 S-550 车③a **改名并归一**:旧事件名 `mailbox_peer_meta_corrupt` 在 `hop_chain` 也变成 fail-closed 的
|
|
62
|
+
* 那一刻就说不出真话了(同一条读腿两列同判)。一个事件 + 一个 `column` 字段,而不是两个事件名 —— 运维
|
|
63
|
+
* 面按 column 分桶,店里只有一条留痕规则。 */
|
|
61
64
|
export interface MailboxStoreLogger {
|
|
62
65
|
warn(event: string, fields: Record<string, unknown>): void;
|
|
63
66
|
}
|