@sema-agent/server 7.93.7 → 7.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/USAGE.md +52 -0
- package/dist/boot/resolve-spec.js +14 -1
- package/dist/boot/stage-09-leader.js +1 -1
- package/dist/boot/stores.js +6 -3
- package/dist/boot/workflow-orchestration.js +5 -1
- package/dist/config-catalog.js +5 -3
- package/dist/config-center/types.d.ts +1 -1
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +17 -0
- package/dist/config-types.d.ts +44 -3
- package/dist/config-types.js +1 -0
- package/dist/config.js +24 -4
- package/dist/hosted-posture.d.ts +176 -0
- package/dist/hosted-posture.js +76 -0
- 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 +62 -42
- package/dist/http/routes/capabilities.js +3 -0
- 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/http/wire-types.d.ts +13 -4
- 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 +4 -10
- package/dist/usage-analytics.d.ts +14 -1
- package/dist/usage-analytics.js +11 -17
- package/package.json +2 -2
|
@@ -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
|
}
|
|
@@ -2,6 +2,7 @@ import { MAILBOX_INVALID_PEER_META_CODE, MAILBOX_TOMBSTONED_RECIPIENT_CODE, Mail
|
|
|
2
2
|
import { pgHasUnstorable, pgSanitizeText, PgUnstorableError } from "./pg-safe-json.js";
|
|
3
3
|
import { ensureIndexes } from "./ensure-index.js";
|
|
4
4
|
import { escapeLike } from "./sql-escape.js";
|
|
5
|
+
import { decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readStringArrayColumn } from "./sql-json-column.js";
|
|
5
6
|
export const MAILBOX_TABLE = "mailbox";
|
|
6
7
|
export const MAILBOX_MSG_TABLE = "mailbox_message";
|
|
7
8
|
export const MAILBOX_TOMBSTONE_TABLE = "mailbox_tombstone";
|
|
@@ -46,19 +47,13 @@ function encodeHopChain(hopChain) {
|
|
|
46
47
|
}
|
|
47
48
|
return text;
|
|
48
49
|
}
|
|
49
|
-
function decodeHopChain(raw) {
|
|
50
|
-
if (raw === null || raw === undefined)
|
|
51
|
-
return undefined;
|
|
52
|
-
let parsed;
|
|
50
|
+
function decodeHopChain(raw, seq) {
|
|
53
51
|
try {
|
|
54
|
-
|
|
52
|
+
return decodeNullableJsonColumn(raw, readStringArrayColumn, `${MAILBOX_MSG_TABLE}.hop_chain for seq ${String(seq)}`);
|
|
55
53
|
}
|
|
56
|
-
catch {
|
|
57
|
-
|
|
54
|
+
catch (e) {
|
|
55
|
+
throw foldJsonColumn(e, (m) => new Error(`MailboxStore: ${m} — refusing the lease rather than let a message skip peer admission (an absent chain makes core's judgePeerAdmission not run at all)`));
|
|
58
56
|
}
|
|
59
|
-
if (!Array.isArray(parsed) || parsed.some((x) => typeof x !== "string"))
|
|
60
|
-
return undefined;
|
|
61
|
-
return parsed;
|
|
62
57
|
}
|
|
63
58
|
const PEER_META_MAX_BYTES = 60_000;
|
|
64
59
|
function encodePeerMeta(raw) {
|
|
@@ -71,52 +66,59 @@ function encodePeerMeta(raw) {
|
|
|
71
66
|
}
|
|
72
67
|
return text;
|
|
73
68
|
}
|
|
74
|
-
function decodePeerMeta(raw) {
|
|
75
|
-
if (raw === null || raw === undefined)
|
|
76
|
-
return undefined;
|
|
77
|
-
const text = bufToStr(raw);
|
|
78
|
-
let parsed;
|
|
69
|
+
function decodePeerMeta(raw, seq) {
|
|
79
70
|
try {
|
|
80
|
-
|
|
71
|
+
return decodeNullableJsonColumn(raw, readPeerMetaColumn, `${MAILBOX_MSG_TABLE}.peer_meta for seq ${String(seq)}`);
|
|
81
72
|
}
|
|
82
|
-
catch (
|
|
83
|
-
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e instanceof MailboxStoreError)
|
|
75
|
+
throw e;
|
|
76
|
+
throw foldJsonColumn(e, (m) => new MailboxStoreError(MAILBOX_INVALID_PEER_META_CODE, `MailboxStore: ${m} — refusing the lease rather than read a corrupt typed record as "no metadata"`));
|
|
84
77
|
}
|
|
78
|
+
}
|
|
79
|
+
const readPeerMetaColumn = (body) => {
|
|
85
80
|
try {
|
|
86
|
-
|
|
81
|
+
const meta = readMailboxPeerMeta(body);
|
|
82
|
+
return meta === undefined ? { problem: "the JSON literal `null` — absence is SQL NULL on this column, so a written `null` can only be a hand edit" } : { value: meta };
|
|
87
83
|
}
|
|
88
84
|
catch (err) {
|
|
89
85
|
if (err instanceof MailboxStoreError)
|
|
90
86
|
throw err;
|
|
91
|
-
|
|
87
|
+
return { problem: err instanceof Error ? err.message : String(err) };
|
|
92
88
|
}
|
|
93
|
-
}
|
|
89
|
+
};
|
|
94
90
|
function rowsToMessages(rows, scope, handle, logger) {
|
|
95
91
|
const out = [];
|
|
96
92
|
for (const r of rows) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
logger?.warn("
|
|
103
|
-
|
|
104
|
-
|
|
93
|
+
const read = (column, decode) => {
|
|
94
|
+
try {
|
|
95
|
+
return decode();
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
logger?.warn("mailbox_message_column_corrupt", {
|
|
99
|
+
scope,
|
|
100
|
+
handle,
|
|
101
|
+
seq: Number(r.seq),
|
|
102
|
+
column,
|
|
103
|
+
...(err instanceof MailboxStoreError ? { code: err.code } : {}),
|
|
104
|
+
note: "claimLease refused for the whole box (fail-closed); repair or NULL that column of this row",
|
|
105
|
+
});
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const hopChain = read("hop_chain", () => decodeHopChain(r.hop_chain, r.seq));
|
|
110
|
+
const peerMeta = read("peer_meta", () => decodePeerMeta(r.peer_meta, r.seq));
|
|
111
|
+
out.push({
|
|
112
|
+
seq: Number(r.seq),
|
|
113
|
+
...(r.from_id !== null && r.from_id !== undefined ? { from: bufToStr(r.from_id) } : {}),
|
|
114
|
+
content: String(r.content),
|
|
115
|
+
sentAt: Number(r.sent_at_ms),
|
|
116
|
+
...(hopChain !== undefined ? { hopChain } : {}),
|
|
117
|
+
...(peerMeta !== undefined ? { peerMeta } : {}),
|
|
118
|
+
});
|
|
105
119
|
}
|
|
106
120
|
return out;
|
|
107
121
|
}
|
|
108
|
-
function rowToMessage(r) {
|
|
109
|
-
const hopChain = decodeHopChain(r.hop_chain);
|
|
110
|
-
const peerMeta = decodePeerMeta(r.peer_meta);
|
|
111
|
-
return {
|
|
112
|
-
seq: Number(r.seq),
|
|
113
|
-
...(r.from_id !== null && r.from_id !== undefined ? { from: bufToStr(r.from_id) } : {}),
|
|
114
|
-
content: String(r.content),
|
|
115
|
-
sentAt: Number(r.sent_at_ms),
|
|
116
|
-
...(hopChain !== undefined ? { hopChain } : {}),
|
|
117
|
-
...(peerMeta !== undefined ? { peerMeta } : {}),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
122
|
export async function ensureTiDBMailboxSchema(pool) {
|
|
121
123
|
await pool.query(`CREATE TABLE IF NOT EXISTS ${MAILBOX_TABLE} (
|
|
122
124
|
scope_key VARBINARY(190) NOT NULL,
|
|
@@ -250,7 +252,7 @@ export class TiDBMailboxStore {
|
|
|
250
252
|
bufToStr(box.lease_owner) !== owner) {
|
|
251
253
|
return null;
|
|
252
254
|
}
|
|
253
|
-
const [msgs] = (await c.query(`SELECT seq, from_id, content, sent_at_ms, hop_chain, peer_meta FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? ORDER BY seq FOR UPDATE`, [scope, handle]));
|
|
255
|
+
const [msgs] = (await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("tidb", "hop_chain")}, ${jsonColumnText("tidb", "peer_meta")} FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? ORDER BY seq FOR UPDATE`, [scope, handle]));
|
|
254
256
|
if (msgs.length === 0)
|
|
255
257
|
return null;
|
|
256
258
|
const maxSeq = Number(msgs[msgs.length - 1].seq);
|
|
@@ -397,7 +399,7 @@ export class PgMailboxStore {
|
|
|
397
399
|
const box = boxes[0];
|
|
398
400
|
if (box.lease_owner !== null && Number(box.lease_expires_at_ms) > now && String(box.lease_owner) !== owner)
|
|
399
401
|
return null;
|
|
400
|
-
const { rows: msgs } = await c.query(`SELECT seq, from_id, content, sent_at_ms, hop_chain, peer_meta FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 ORDER BY seq FOR UPDATE`, [scope, handle]);
|
|
402
|
+
const { rows: msgs } = await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("pg", "hop_chain")}, ${jsonColumnText("pg", "peer_meta")} FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 ORDER BY seq FOR UPDATE`, [scope, handle]);
|
|
401
403
|
if (msgs.length === 0)
|
|
402
404
|
return null;
|
|
403
405
|
const maxSeq = Number(msgs[msgs.length - 1].seq);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { PG_MEMORY_ENGINE_TABLES } from "./memory-engine-pg.js";
|
|
3
|
+
import { createZodColumnReader, decodeJsonColumn, jsonColumnText, SqlJsonColumnError } from "./sql-json-column.js";
|
|
3
4
|
export const PG_MEMORY_ENGINE_META_TABLE = "agent_memory_engine_meta";
|
|
4
5
|
export const EMBEDDER_IDENTITY_META_KEY = "embedder_identity";
|
|
5
6
|
export const CAS_MAX_ROUNDS = 3;
|
|
@@ -13,28 +14,23 @@ export async function ensurePgMemoryEmbedderMetaSchema(query) {
|
|
|
13
14
|
meta_value jsonb NOT NULL
|
|
14
15
|
)`);
|
|
15
16
|
}
|
|
17
|
+
const readEmbedderIdentityColumn = createZodColumnReader(EmbedderIdentitySchema);
|
|
16
18
|
function parseStoredText(text) {
|
|
17
|
-
let candidate;
|
|
18
19
|
try {
|
|
19
|
-
|
|
20
|
+
return { kind: "ok", value: decodeJsonColumn(text, readEmbedderIdentityColumn, `${PG_MEMORY_ENGINE_META_TABLE}.meta_value for key ${EMBEDDER_IDENTITY_META_KEY}`) };
|
|
20
21
|
}
|
|
21
22
|
catch (err) {
|
|
22
|
-
|
|
23
|
+
if (!(err instanceof SqlJsonColumnError))
|
|
24
|
+
throw err;
|
|
25
|
+
return { kind: "unreadable", raw: `${text.slice(0, 160)} [${err.problem}: ${err.message.slice(0, 120)}]` };
|
|
23
26
|
}
|
|
24
|
-
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
25
|
-
return { kind: "unreadable", raw: `${text.slice(0, 160)} [not a JSON object]` };
|
|
26
|
-
}
|
|
27
|
-
const parsed = EmbedderIdentitySchema.safeParse(candidate);
|
|
28
|
-
if (!parsed.success)
|
|
29
|
-
return { kind: "unreadable", raw: text.slice(0, 200) };
|
|
30
|
-
return { kind: "ok", value: parsed.data };
|
|
31
27
|
}
|
|
32
28
|
async function readStored(query) {
|
|
33
|
-
const r = await query(`SELECT meta_value
|
|
29
|
+
const r = await query(`SELECT ${jsonColumnText("pg", "meta_value")} FROM ${PG_MEMORY_ENGINE_META_TABLE} WHERE meta_key = $1`, [EMBEDDER_IDENTITY_META_KEY]);
|
|
34
30
|
const row = r.rows[0];
|
|
35
31
|
if (row === undefined)
|
|
36
32
|
return { kind: "absent" };
|
|
37
|
-
const text = row.
|
|
33
|
+
const text = row.meta_value;
|
|
38
34
|
if (typeof text !== "string")
|
|
39
35
|
return { kind: "unreadable", raw: `[meta_value::text did not return text: ${typeof text}]` };
|
|
40
36
|
return parseStoredText(text);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { cosineDistance, jaccardDistance, termSet } from "@sema-agent/core";
|
|
2
|
-
import { isUniqueViolation } from "./memory-engine-vector-util.js";
|
|
2
|
+
import { isUniqueViolation, readEmbeddingColumn, readFrontmatterColumn } from "./memory-engine-vector-util.js";
|
|
3
|
+
import { decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText } from "./sql-json-column.js";
|
|
4
|
+
import { recordFailOpen } from "../observability/fail-open.js";
|
|
3
5
|
import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
|
|
4
6
|
import { pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
|
|
5
7
|
import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js";
|
|
@@ -44,8 +46,24 @@ function vectorLiteral(v) {
|
|
|
44
46
|
function inPlaceholders(first, count) {
|
|
45
47
|
return Array.from({ length: count }, (_, i) => `$${first + i}`).join(", ");
|
|
46
48
|
}
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
+
function readFrontmatter(cell, id) {
|
|
50
|
+
try {
|
|
51
|
+
return decodeJsonColumn(cell, readFrontmatterColumn, `${PG_MEMORY_ENGINE_TABLES.entry}.frontmatter for entry ${String(id)}`);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw foldJsonColumn(e, (m) => new Error(`memory-engine: ${m} — refusing rather than serve a note whose frontmatter (the origin/exposure law carrier) nobody can read`));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function readEmbedding(cell, id) {
|
|
58
|
+
try {
|
|
59
|
+
return decodeNullableJsonColumn(cell, readEmbeddingColumn, `${PG_MEMORY_ENGINE_TABLES.entry}.embedding for entry ${String(id)}`);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (!isJsonColumnBodyProblem(e))
|
|
63
|
+
throw e;
|
|
64
|
+
recordFailOpen("server.sql-json.memory-engine.embedding");
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
49
67
|
}
|
|
50
68
|
export class PgMemoryEngineBackend {
|
|
51
69
|
query;
|
|
@@ -78,7 +96,7 @@ export class PgMemoryEngineBackend {
|
|
|
78
96
|
return this.pgvectorSql ? vectorLiteral(v) : pgSafeJsonStringify(v);
|
|
79
97
|
}
|
|
80
98
|
headerFromRow(r) {
|
|
81
|
-
const fm =
|
|
99
|
+
const fm = readFrontmatter(r.frontmatter, r.id);
|
|
82
100
|
return {
|
|
83
101
|
id: r.id,
|
|
84
102
|
slug: r.slug,
|
|
@@ -98,7 +116,7 @@ export class PgMemoryEngineBackend {
|
|
|
98
116
|
id: r.id,
|
|
99
117
|
slug: r.slug,
|
|
100
118
|
scope: r.scope,
|
|
101
|
-
frontmatter:
|
|
119
|
+
frontmatter: readFrontmatter(r.frontmatter, r.id),
|
|
102
120
|
body: r.body,
|
|
103
121
|
rev: r.rev,
|
|
104
122
|
};
|
|
@@ -107,14 +125,14 @@ export class PgMemoryEngineBackend {
|
|
|
107
125
|
const uniq = [...new Set(scopes)];
|
|
108
126
|
if (uniq.length === 0)
|
|
109
127
|
return [];
|
|
110
|
-
const res = await this.query(`SELECT id, scope, slug, frontmatter, rev, mtime_ms, size_bytes FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE scope IN (${inPlaceholders(1, uniq.length)}) ORDER BY id`, uniq);
|
|
128
|
+
const res = await this.query(`SELECT id, scope, slug, ${jsonColumnText("pg", "frontmatter")}, rev, mtime_ms, size_bytes FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE scope IN (${inPlaceholders(1, uniq.length)}) ORDER BY id`, uniq);
|
|
111
129
|
return res.rows.map((r) => this.headerFromRow(r));
|
|
112
130
|
}
|
|
113
131
|
async getByIds(ids) {
|
|
114
132
|
const uniq = [...new Set(ids)];
|
|
115
133
|
if (uniq.length === 0)
|
|
116
134
|
return [];
|
|
117
|
-
const res = await this.query(`SELECT id, scope, slug, frontmatter, body, rev, mtime_ms, size_bytes FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE id IN (${inPlaceholders(1, uniq.length)}) ORDER BY id`, uniq);
|
|
135
|
+
const res = await this.query(`SELECT id, scope, slug, ${jsonColumnText("pg", "frontmatter")}, body, rev, mtime_ms, size_bytes FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE id IN (${inPlaceholders(1, uniq.length)}) ORDER BY id`, uniq);
|
|
118
136
|
return res.rows.map((r) => this.entryFromRow(r));
|
|
119
137
|
}
|
|
120
138
|
async search(query, scopes, opts) {
|
|
@@ -132,7 +150,7 @@ export class PgMemoryEngineBackend {
|
|
|
132
150
|
if (Array.isArray(qv) && qv.length === this.embedder.dimensions) {
|
|
133
151
|
const vecParam = scopes.length + 1;
|
|
134
152
|
const bandExpr = bands ? `(CASE WHEN ${originMarkedSqlPredicate("frontmatter")} THEN 1 ELSE 0 END) ASC, ` : "";
|
|
135
|
-
const res = await this.query(`SELECT id, scope, slug, frontmatter, rev, mtime_ms, size_bytes, embedding <=> $${vecParam}::vector AS dist
|
|
153
|
+
const res = await this.query(`SELECT id, scope, slug, ${jsonColumnText("pg", "frontmatter")}, rev, mtime_ms, size_bytes, embedding <=> $${vecParam}::vector AS dist
|
|
136
154
|
FROM ${PG_MEMORY_ENGINE_TABLES.entry}
|
|
137
155
|
WHERE scope IN (${inPlaceholders(1, scopes.length)}) AND embedding IS NOT NULL
|
|
138
156
|
ORDER BY ${bandExpr}embedding <=> $${vecParam}::vector ASC, id ASC
|
|
@@ -141,16 +159,16 @@ export class PgMemoryEngineBackend {
|
|
|
141
159
|
return hits.sort(contractOrder);
|
|
142
160
|
}
|
|
143
161
|
}
|
|
144
|
-
const res = await this.query(`SELECT id, scope, slug, frontmatter, body, rev, mtime_ms, size_bytes, embedding FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE scope IN (${inPlaceholders(1, scopes.length)})`, [...scopes]);
|
|
162
|
+
const res = await this.query(`SELECT id, scope, slug, ${jsonColumnText("pg", "frontmatter")}, body, rev, mtime_ms, size_bytes, ${jsonColumnText("pg", "embedding")} FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE scope IN (${inPlaceholders(1, scopes.length)})`, [...scopes]);
|
|
145
163
|
const queryVec = this.embedder ? await this.embedder.embed(query).catch(() => null) : null;
|
|
146
164
|
const dims = this.embedder?.dimensions;
|
|
147
165
|
const scored = [];
|
|
148
166
|
for (const raw of res.rows) {
|
|
149
167
|
const r = raw;
|
|
150
|
-
const fm =
|
|
168
|
+
const fm = readFrontmatter(r.frontmatter, r.id);
|
|
151
169
|
if (queryVec && Array.isArray(queryVec) && queryVec.length === dims && r.embedding != null) {
|
|
152
|
-
const stored =
|
|
153
|
-
if (
|
|
170
|
+
const stored = readEmbedding(r.embedding, r.id);
|
|
171
|
+
if (stored !== undefined && stored.length === dims) {
|
|
154
172
|
scored.push({ ...this.headerFromRow(r), score: cosineDistance(queryVec, stored) });
|
|
155
173
|
continue;
|
|
156
174
|
}
|
|
@@ -233,10 +251,10 @@ export class PgMemoryEngineBackend {
|
|
|
233
251
|
return;
|
|
234
252
|
}
|
|
235
253
|
const entry = rawEntry;
|
|
236
|
-
const prior = await this.query(`SELECT scope, slug, rev, frontmatter FROM ${T} WHERE id = $1`, [entry.id]);
|
|
254
|
+
const prior = await this.query(`SELECT scope, slug, rev, ${jsonColumnText("pg", "frontmatter")} FROM ${T} WHERE id = $1`, [entry.id]);
|
|
237
255
|
const priorRow = prior.rows.length > 0 ? prior.rows[0] : undefined;
|
|
238
256
|
{
|
|
239
|
-
const committedBaseFm = rememberBatchCommitted(originBaseline, entry.id, priorRow !== undefined ?
|
|
257
|
+
const committedBaseFm = rememberBatchCommitted(originBaseline, entry.id, priorRow !== undefined ? readFrontmatter(priorRow.frontmatter, entry.id) : undefined);
|
|
240
258
|
const refusal = originWhitewashRefusal("add", committedBaseFm, entry.frontmatter);
|
|
241
259
|
if (refusal !== undefined) {
|
|
242
260
|
report.conflicts.push({ op: "add", id: entry.id, reason: refusal });
|
|
@@ -304,7 +322,7 @@ export class PgMemoryEngineBackend {
|
|
|
304
322
|
slug = `${entry.slug}-${n}`;
|
|
305
323
|
}
|
|
306
324
|
}
|
|
307
|
-
const cur = await this.query(`SELECT slug, rev, scope, frontmatter FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE id = $1`, [patch.id]);
|
|
325
|
+
const cur = await this.query(`SELECT slug, rev, scope, ${jsonColumnText("pg", "frontmatter")} FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE id = $1`, [patch.id]);
|
|
308
326
|
if (cur.rows.length === 0) {
|
|
309
327
|
report.conflicts.push({ op: patch.op, id: patch.id, reason: "unknown id (entry not found in backend)" });
|
|
310
328
|
return;
|
|
@@ -312,7 +330,7 @@ export class PgMemoryEngineBackend {
|
|
|
312
330
|
const currentRev = String(cur.rows[0].rev);
|
|
313
331
|
const currentSlug = String(cur.rows[0].slug);
|
|
314
332
|
const currentScope = String(cur.rows[0].scope);
|
|
315
|
-
const committedFm =
|
|
333
|
+
const committedFm = readFrontmatter(cur.rows[0].frontmatter, patch.id);
|
|
316
334
|
const committedBaseFm = rememberBatchCommitted(originBaseline, patch.id, committedFm);
|
|
317
335
|
if (patch.op === "update" && patch.entry !== undefined) {
|
|
318
336
|
if (committedFm.provenance?.kind === "repo_file") {
|