@sema-agent/server 7.118.0 → 7.119.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.
Files changed (101) hide show
  1. package/MIGRATION.md +85 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +24 -13
  5. package/deploy/sema-up/README.md +2 -0
  6. package/deploy/sema-up/chart/templates/minio.yaml +2 -1
  7. package/deploy/sema-up/chart/templates/server-v4pro.yaml +1 -0
  8. package/deploy/sema-up/chart/templates/server.yaml +1 -0
  9. package/deploy/sema-up/chart/values.yaml +7 -1
  10. package/deploy/sema-up/cluster-up.sh +20 -8
  11. package/deploy/sema-up/kube-up.sh +29 -16
  12. package/deploy/sema-up/lib/buckets.sh +21 -0
  13. package/deploy/sema-up/lib/health.sh +33 -0
  14. package/deploy/sema-up/sema-up.sh +14 -9
  15. package/deploy/sema-up/smoke.sh +16 -6
  16. package/deploy/sema-up/stack/compose.yaml +1 -1
  17. package/deploy/sema-up/stack/sema-stack-cluster.yaml +4 -1
  18. package/dist/approval-ask-audit-store.js +2 -7
  19. package/dist/approval-card.d.ts +5 -5
  20. package/dist/approval.d.ts +51 -5
  21. package/dist/approval.js +76 -13
  22. package/dist/audit.d.ts +12 -1
  23. package/dist/audit.js +62 -2
  24. package/dist/auto-mode-face.d.ts +2 -3
  25. package/dist/bake-runner/main.js +1 -6
  26. package/dist/bake-runner/protocol.d.ts +18 -0
  27. package/dist/bake-runner/protocol.js +22 -2
  28. package/dist/bake-runner/runner.d.ts +12 -8
  29. package/dist/bake-runner/runner.js +5 -9
  30. package/dist/boot/config-center.js +1 -1
  31. package/dist/boot/coordinators.js +4 -1
  32. package/dist/boot/device-lane.d.ts +3 -3
  33. package/dist/boot/device-lane.js +1 -2
  34. package/dist/boot/leader.d.ts +4 -4
  35. package/dist/boot/leader.js +1 -2
  36. package/dist/boot/parked-revive-gate.d.ts +5 -1
  37. package/dist/boot/parked-revive-gate.js +8 -2
  38. package/dist/boot/resolve-spec.js +7 -4
  39. package/dist/boot/stage-07-capability-layer.js +5 -1
  40. package/dist/boot/stage-10-http-server.js +3 -2
  41. package/dist/boot/stores.js +12 -7
  42. package/dist/capabilities/center-plugins.d.ts +2 -0
  43. package/dist/capabilities/center-plugins.js +24 -6
  44. package/dist/capabilities/code-review-council.js +8 -11
  45. package/dist/capabilities/scenarios.d.ts +34 -7
  46. package/dist/capabilities/scenarios.js +33 -6
  47. package/dist/config-catalog.js +3 -3
  48. package/dist/config.js +2 -2
  49. package/dist/deployment-governance.d.ts +5 -2
  50. package/dist/deployment-governance.js +4 -3
  51. package/dist/host-decision.d.ts +52 -27
  52. package/dist/host-decision.js +14 -7
  53. package/dist/http/admission.js +6 -6
  54. package/dist/http/resume-legs.js +2 -1
  55. package/dist/http/routes/approvals-assistant.d.ts +25 -3
  56. package/dist/http/routes/approvals-assistant.js +9 -5
  57. package/dist/http/routes/capabilities.js +4 -3
  58. package/dist/http/routes/memory-origin.d.ts +1 -1
  59. package/dist/http/routes/runs.js +11 -5
  60. package/dist/http/routes/sessions.js +20 -16
  61. package/dist/http/wire-types.d.ts +12 -13
  62. package/dist/images/manifest.d.ts +7 -1
  63. package/dist/images/manifest.js +18 -16
  64. package/dist/memory-store-refusal.d.ts +7 -12
  65. package/dist/memory-store-refusal.js +4 -5
  66. package/dist/namespaced-rule-name.d.ts +4 -3
  67. package/dist/observability/fail-open.d.ts +5 -1
  68. package/dist/observability/fail-open.js +5 -1
  69. package/dist/operator-ask.d.ts +6 -5
  70. package/dist/plugins/approval-exemption-store.d.ts +4 -0
  71. package/dist/plugins/approval-exemption-store.js +1 -0
  72. package/dist/plugins/blob-backend.d.ts +13 -0
  73. package/dist/plugins/blob-backend.js +26 -0
  74. package/dist/plugins/local-session-store.d.ts +78 -47
  75. package/dist/plugins/local-session-store.js +152 -122
  76. package/dist/plugins/one-time-migrations.d.ts +6 -0
  77. package/dist/plugins/one-time-migrations.js +19 -1
  78. package/dist/plugins/snapshot-bucket-presence.d.ts +36 -0
  79. package/dist/plugins/snapshot-bucket-presence.js +40 -0
  80. package/dist/plugins/store-backend.d.ts +17 -6
  81. package/dist/plugins/store-backend.js +46 -16
  82. package/dist/plugins/store-open-wait.d.ts +33 -0
  83. package/dist/plugins/store-open-wait.js +73 -0
  84. package/dist/rule-entry-list.js +1 -1
  85. package/dist/run-local.js +4 -3
  86. package/dist/security.d.ts +5 -3
  87. package/dist/security.js +4 -0
  88. package/dist/session-policy-wire.js +7 -12
  89. package/dist/spec-fields.d.ts +6 -2
  90. package/dist/store-posture.d.ts +14 -6
  91. package/dist/store-posture.js +7 -4
  92. package/dist/task-settings.d.ts +27 -55
  93. package/dist/task-settings.js +6 -18
  94. package/dist/tool-approval.d.ts +97 -43
  95. package/dist/tool-approval.js +120 -31
  96. package/dist/trace/redact.d.ts +6 -0
  97. package/dist/trace/redact.js +9 -9
  98. package/dist/trace/self-minted-wire.d.ts +1 -1
  99. package/dist/user-input-ledger.d.ts +19 -15
  100. package/dist/user-input-ledger.js +12 -20
  101. package/package.json +2 -2
@@ -1,13 +1,17 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
- import { SessionError, validateEntriesForImport } from "@sema-agent/core";
3
+ import { FileSessionRepo, SessionError, validateEntriesForImport } from "@sema-agent/core";
4
+ import { z } from "zod";
4
5
  import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
5
6
  import { quarantineUnreadableSidecar } from "./local-sidecar-quarantine.js";
6
- import { decodeJsonColumn, readStringRecordColumn, SqlJsonColumnError } from "./sql-json-column.js";
7
+ import { createZodColumnReader, decodeJsonColumn, readStringRecordColumn, SqlJsonColumnError } from "./sql-json-column.js";
8
+ import { ONE_TIME_MIGRATIONS, migrationWindowOpen } from "./one-time-migrations.js";
9
+ import { createLogger } from "../observability/logger.js";
7
10
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
8
11
  import { carriedIncarnation, mintSessionIncarnation, sessionFaceFromRow } from "../session-incarnation.js";
9
12
  import { decodeInstructionSource, encodeInstructionSource, sameInstructionSourceBaseline } from "../instruction-source-notice.js";
10
- import { openSessionForItself } from "../session-records-skipped-notice.js";
13
+ import { noteReplayInSessionOpenScope, openSessionForItself } from "../session-records-skipped-notice.js";
14
+ import { createCorruptReadFailOpenSeat } from "../observability/corrupt-read-seat.js";
11
15
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
12
16
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
13
17
  const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
@@ -30,18 +34,32 @@ function userText(entry) {
30
34
  }
31
35
  return null;
32
36
  }
33
- const SIDECAR_DEGRADES = { lastActivity: "recency", title: "auto-title", "instruction-source": "instruction-source baseline", incarnation: "session-incarnation (the write that needed it failed loudly)" };
37
+ const SIDECAR_DEGRADES = { lastActivity: "recency", title: "auto-title", "instruction-source": "instruction-source baseline", "session-meta": "session owner + incarnation (the write that needed it failed loudly)" };
38
+ function readSessionMetaCell(cell) {
39
+ return typeof cell === "string" ? { incarnation: cell, owner: undefined } : cell;
40
+ }
41
+ const ownerRecorded = (cell) => cell !== undefined && readSessionMetaCell(cell).owner !== undefined;
42
+ const SESSION_META_SIDECAR = z.record(z.string(), z.union([z.string(), z.object({ incarnation: z.string(), owner: z.string().nullable() }).strict()]));
43
+ const readSessionMetaSidecar = createZodColumnReader(SESSION_META_SIDECAR);
44
+ function ownerClaimWindowOpen() {
45
+ const window = ONE_TIME_MIGRATIONS.find((w) => w.id === "M10");
46
+ if (window === undefined)
47
+ throw new Error("LocalSessionStore: the one-time migration registry has no M10 row (the local session-owner claim window) — the claim arm outlived its registry row; delete it with that row");
48
+ return migrationWindowOpen(window, new Date().toISOString().slice(0, 10));
49
+ }
34
50
  const SIDECAR_MAP_STATE_BY_PATH = new Map();
35
- class SidecarStringMap {
51
+ class SidecarMap {
36
52
  path;
37
53
  label;
54
+ read;
38
55
  onUnreadable;
39
56
  onWriteFailed;
40
57
  strict;
41
58
  own = { entries: new Map(), writeChain: Promise.resolve() };
42
- constructor(path, label, onUnreadable, onWriteFailed, strict = false) {
59
+ constructor(path, label, read, onUnreadable, onWriteFailed, strict = false) {
43
60
  this.path = path;
44
61
  this.label = label;
62
+ this.read = read;
45
63
  this.onUnreadable = onUnreadable;
46
64
  this.onWriteFailed = onWriteFailed;
47
65
  this.strict = strict;
@@ -59,7 +77,7 @@ class SidecarStringMap {
59
77
  if (!st.loaded) {
60
78
  st.loaded = (async () => {
61
79
  try {
62
- const parsed = decodeJsonColumn(await readFile(path, "utf8"), readStringRecordColumn, this.label);
80
+ const parsed = decodeJsonColumn(await readFile(path, "utf8"), this.read, this.label);
63
81
  for (const [id, v] of Object.entries(parsed)) {
64
82
  if (!st.entries.has(id))
65
83
  st.entries.set(id, v);
@@ -73,45 +91,60 @@ class SidecarStringMap {
73
91
  return;
74
92
  }
75
93
  const kind = e instanceof SqlJsonColumnError ? e.problem : (e?.code ?? "unreadable");
76
- throw new Error(`${this.label} is unreadable (${kind}) — refusing to read or mint session incarnations until it is repaired: a replacement ` +
77
- `token for an existing session would make it a different occupant (its memory opt-out would stop binding it), and a write ` +
78
- `would overwrite the unreadable bytes. The file is left in place at ${path}.`);
94
+ throw new Error(`${this.label} is unreadable (${kind}) — refusing to read or write session owners and incarnations until it is repaired: ` +
95
+ `reading it as empty would turn every session ownerless, and a replacement incarnation token for an existing session would ` +
96
+ `make it a different occupant (its memory opt-out would stop binding it); a write would overwrite the unreadable bytes. ` +
97
+ `The file is left in place at ${path}.`);
79
98
  }
80
99
  })();
81
100
  }
82
101
  return st.loaded;
83
102
  }
84
103
  persist() {
85
- return this.commit(() => () => undefined);
104
+ return this.commit(() => true);
86
105
  }
87
106
  commit(change) {
88
107
  const path = this.path;
89
108
  const st = this.state;
90
109
  const link = st.writeChain.then(async () => {
91
- const undo = change(st.entries);
92
- if (undo === undefined || !path)
93
- return true;
94
- try {
95
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
96
- await writeFile(tmp, JSON.stringify(Object.fromEntries(st.entries)), "utf8");
97
- await rename(tmp, path);
110
+ const base = new Map(st.entries);
111
+ const draft = new Map(base);
112
+ if (!change(draft))
98
113
  return true;
114
+ if (path) {
115
+ try {
116
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
117
+ await writeFile(tmp, JSON.stringify(Object.fromEntries(draft)), "utf8");
118
+ await rename(tmp, path);
119
+ }
120
+ catch (e) {
121
+ this.onWriteFailed(e);
122
+ return false;
123
+ }
99
124
  }
100
- catch (e) {
101
- undo();
102
- this.onWriteFailed(e);
103
- return false;
125
+ for (const key of new Set([...base.keys(), ...draft.keys()])) {
126
+ if (draft.has(key) === base.has(key) && draft.get(key) === base.get(key))
127
+ continue;
128
+ if (draft.has(key))
129
+ st.entries.set(key, draft.get(key));
130
+ else
131
+ st.entries.delete(key);
104
132
  }
133
+ return true;
105
134
  });
106
135
  st.writeChain = link.then(() => undefined);
107
136
  return link;
108
137
  }
109
138
  }
139
+ export function createLocalSessionRepo(root) {
140
+ return new FileSessionRepo(root, {
141
+ onCorruptRead: createCorruptReadFailOpenSeat("server.session-repo.corrupt-record-skipped", noteReplayInSessionOpenScope),
142
+ });
143
+ }
110
144
  export class LocalSessionStore {
111
145
  repo;
112
146
  retention = UNMANAGED_RETENTION;
113
147
  pending = new Map();
114
- owners = new Map();
115
148
  lastActivity = new Map();
116
149
  lastTaskRun = new Map();
117
150
  stagings = new Map();
@@ -124,48 +157,63 @@ export class LocalSessionStore {
124
157
  sidecarPath;
125
158
  titles;
126
159
  instructionSources;
127
- incarnations;
128
- constructor(repo, dataDir) {
160
+ meta;
161
+ log;
162
+ constructor(repo, dataDir, opts) {
129
163
  this.repo = repo;
130
164
  this.sidecarPath = dataDir ? join(dataDir, "session-last-activity.json") : undefined;
131
- const sidecar = (file, which, strict = false) => new SidecarStringMap(dataDir ? resolve(join(dataDir, file)) : undefined, `${file} (the local ${which} sidecar)`, (path, e) => this.refuseSidecar(path, `LocalSessionStore ${which} sidecar`, e, which), (e) => this.warnSidecar("write", e, which), strict);
132
- this.titles = sidecar("session-titles.json", "title");
133
- this.instructionSources = sidecar("session-instruction-sources.json", "instruction-source");
134
- this.incarnations = sidecar("session-incarnations.json", "incarnation", true);
135
- }
136
- async writeIncarnation(sessionId, next) {
137
- await this.incarnations.ensureLoaded();
138
- const committed = await this.incarnations.commit((entries) => {
139
- const before = entries.get(sessionId);
140
- const value = next(before);
141
- if (value === undefined || value === before)
142
- return undefined;
143
- entries.set(sessionId, value);
144
- return () => {
145
- if (before === undefined)
146
- entries.delete(sessionId);
147
- else
148
- entries.set(sessionId, before);
149
- };
165
+ this.log = opts?.log ?? createLogger();
166
+ const sidecar = (file, which, read, strict = false) => new SidecarMap(dataDir ? resolve(join(dataDir, file)) : undefined, `${file} (the local ${which} sidecar)`, read, (path, e) => this.refuseSidecar(path, `LocalSessionStore ${which} sidecar`, e, which), (e) => this.warnSidecar("write", e, which), strict);
167
+ this.titles = sidecar("session-titles.json", "title", readStringRecordColumn);
168
+ this.instructionSources = sidecar("session-instruction-sources.json", "instruction-source", readStringRecordColumn);
169
+ this.meta = sidecar("session-incarnations.json", "session-meta", readSessionMetaSidecar, true);
170
+ }
171
+ async writeMeta(sessionId, next) {
172
+ await this.meta.ensureLoaded();
173
+ let changed = false;
174
+ const committed = await this.meta.commit((draft) => {
175
+ const value = next(draft.get(sessionId));
176
+ if (value === undefined)
177
+ return false;
178
+ draft.set(sessionId, value);
179
+ changed = true;
180
+ return true;
150
181
  });
151
182
  if (!committed)
152
- throw new SessionError("storage", `LocalSessionStore: session-incarnation sidecar write failed for session ${sessionId} — nothing was recorded`);
183
+ throw new SessionError("storage", `LocalSessionStore: session-meta sidecar write failed for session ${sessionId} — nothing was recorded`);
184
+ return changed;
153
185
  }
154
- mintIncarnation(sessionId) {
155
- return this.writeIncarnation(sessionId, () => mintSessionIncarnation());
186
+ async dropMeta(sessionId) {
187
+ await this.meta.ensureLoaded();
188
+ const committed = await this.meta.commit((draft) => draft.delete(sessionId));
189
+ if (!committed)
190
+ throw new SessionError("storage", `LocalSessionStore: session-meta sidecar write failed while deleting session ${sessionId} — its owner/incarnation cell is still on disk`);
156
191
  }
157
- ensureIncarnation(sessionId) {
158
- return this.writeIncarnation(sessionId, (before) => (before === undefined ? mintSessionIncarnation() : undefined));
192
+ async existsInRepo(sessionId) {
193
+ try {
194
+ await this.repo.open({ id: sessionId, createdAt: "" });
195
+ return true;
196
+ }
197
+ catch (e) {
198
+ if (isNotFound(e))
199
+ return false;
200
+ throw e;
201
+ }
159
202
  }
160
- async sessionFaceOf(sessionId) {
161
- const owner = await this.ownerOf(sessionId);
162
- if (owner === undefined)
163
- return undefined;
164
- await this.ensureIncarnation(sessionId);
165
- const incarnation = this.incarnations.state.entries.get(sessionId);
166
- if (!this.owners.has(sessionId) || (this.owners.get(sessionId) ?? null) !== owner)
203
+ async metaOf(sessionId) {
204
+ await this.meta.ensureLoaded();
205
+ const cell = this.meta.state.entries.get(sessionId);
206
+ if (cell !== undefined)
207
+ return readSessionMetaCell(cell);
208
+ if (!(await this.existsInRepo(sessionId)))
167
209
  return undefined;
168
- return sessionFaceFromRow(sessionId, owner, incarnation);
210
+ await this.writeMeta(sessionId, (before) => (before === undefined ? mintSessionIncarnation() : undefined));
211
+ const minted = this.meta.state.entries.get(sessionId);
212
+ return minted === undefined ? undefined : readSessionMetaCell(minted);
213
+ }
214
+ async sessionFaceOf(sessionId) {
215
+ const meta = await this.metaOf(sessionId);
216
+ return meta === undefined ? undefined : sessionFaceFromRow(sessionId, meta.owner ?? null, meta.incarnation);
169
217
  }
170
218
  async probeTitle(sessionId) {
171
219
  await this.titles.ensureLoaded();
@@ -186,17 +234,11 @@ export class LocalSessionStore {
186
234
  }
187
235
  async recordInstructionSource(sessionId, expected, source) {
188
236
  await this.instructionSources.ensureLoaded();
189
- const committed = await this.instructionSources.commit((entries) => {
190
- const before = entries.get(sessionId);
191
- if (!sameInstructionSourceBaseline(decodeInstructionSource(before), expected))
192
- return undefined;
193
- entries.set(sessionId, encodeInstructionSource(source));
194
- return () => {
195
- if (before === undefined)
196
- entries.delete(sessionId);
197
- else
198
- entries.set(sessionId, before);
199
- };
237
+ const committed = await this.instructionSources.commit((draft) => {
238
+ if (!sameInstructionSourceBaseline(decodeInstructionSource(draft.get(sessionId)), expected))
239
+ return false;
240
+ draft.set(sessionId, encodeInstructionSource(source));
241
+ return true;
200
242
  });
201
243
  if (!committed)
202
244
  throw new Error("LocalSessionStore: instruction-source sidecar write failed — baseline not recorded");
@@ -271,11 +313,10 @@ export class LocalSessionStore {
271
313
  }
272
314
  async acquire(sessionId, opts) {
273
315
  if (!sessionId) {
274
- await this.incarnations.ensureLoaded();
316
+ await this.meta.ensureLoaded();
275
317
  const session = await this.repo.create({});
276
318
  const id = (await session.getMetadata()).id;
277
- this.owners.set(id, null);
278
- await this.mintIncarnation(id);
319
+ await this.writeMeta(id, () => ({ incarnation: mintSessionIncarnation(), owner: null }));
279
320
  return { session: session, sessionId: id };
280
321
  }
281
322
  if (opts?.requireExisting)
@@ -290,8 +331,6 @@ export class LocalSessionStore {
290
331
  async load(sessionId, requireExisting = false) {
291
332
  try {
292
333
  const session = await openSessionForItself(sessionId, () => this.repo.open({ id: sessionId, createdAt: "" }));
293
- if (!this.owners.has(sessionId))
294
- this.owners.set(sessionId, null);
295
334
  return { session: session, sessionId };
296
335
  }
297
336
  catch (e) {
@@ -299,56 +338,47 @@ export class LocalSessionStore {
299
338
  throw e;
300
339
  if (requireExisting)
301
340
  throw e;
302
- await this.incarnations.ensureLoaded();
341
+ await this.meta.ensureLoaded();
303
342
  const session = await this.repo.create({ id: sessionId });
304
- await this.mintIncarnation(sessionId);
305
- if (!this.owners.has(sessionId))
306
- this.owners.set(sessionId, null);
343
+ await this.writeMeta(sessionId, (before) => ({ incarnation: mintSessionIncarnation(), owner: before !== undefined && ownerRecorded(before) ? (readSessionMetaCell(before).owner ?? null) : null }));
307
344
  return { session: session, sessionId };
308
345
  }
309
346
  }
310
347
  async register(sessionId, owner) {
311
- if (this.owners.has(sessionId))
348
+ await this.meta.ensureLoaded();
349
+ if (ownerRecorded(this.meta.state.entries.get(sessionId)))
312
350
  return;
313
- await this.incarnations.ensureLoaded();
314
- this.owners.set(sessionId, owner);
315
- try {
316
- await this.repo.open({ id: sessionId, createdAt: "" });
317
- await this.ensureIncarnation(sessionId);
318
- }
319
- catch (e) {
320
- if (!isNotFound(e))
321
- throw e;
351
+ const existed = await this.existsInRepo(sessionId);
352
+ const claim = existed && owner !== null && ownerClaimWindowOpen();
353
+ const wrote = await this.writeMeta(sessionId, (before) => {
354
+ if (ownerRecorded(before))
355
+ return undefined;
356
+ if (!existed || claim)
357
+ return { incarnation: existed && before !== undefined ? readSessionMetaCell(before).incarnation : mintSessionIncarnation(), owner };
358
+ return before === undefined ? mintSessionIncarnation() : undefined;
359
+ });
360
+ if (!existed && wrote)
322
361
  await this.repo.create({ id: sessionId });
323
- await this.mintIncarnation(sessionId);
324
- }
362
+ if (claim && wrote)
363
+ this.log.info("session_owner_claimed", { sessionId });
325
364
  }
326
365
  async ownerOf(sessionId) {
327
- if (this.owners.has(sessionId))
328
- return this.owners.get(sessionId) ?? null;
329
- try {
330
- await this.repo.open({ id: sessionId, createdAt: "" });
331
- this.owners.set(sessionId, null);
332
- return null;
333
- }
334
- catch (e) {
335
- if (isNotFound(e))
336
- return undefined;
337
- throw e;
338
- }
366
+ const meta = await this.metaOf(sessionId);
367
+ return meta === undefined ? undefined : (meta.owner ?? null);
339
368
  }
340
369
  async getHead(sessionId) {
341
- if (!this.owners.has(sessionId) && (await this.ownerOf(sessionId)) === undefined)
370
+ const before = await this.metaOf(sessionId);
371
+ if (before === undefined)
342
372
  return undefined;
343
- const owner = this.owners.get(sessionId) ?? null;
344
373
  const leafId = await this.getLeafId(sessionId);
345
- if (!this.owners.has(sessionId) || (this.owners.get(sessionId) ?? null) !== owner)
374
+ const cell = this.meta.state.entries.get(sessionId);
375
+ const after = cell === undefined ? undefined : readSessionMetaCell(cell);
376
+ if (after === undefined || after.incarnation !== before.incarnation || (after.owner ?? null) !== (before.owner ?? null))
346
377
  return undefined;
347
- return { owner, leafId };
378
+ return { owner: before.owner ?? null, leafId };
348
379
  }
349
- restampOwner(sessionId, owner) {
350
- if (this.owners.has(sessionId))
351
- this.owners.set(sessionId, owner);
380
+ async restampOwner(sessionId, owner) {
381
+ await this.writeMeta(sessionId, (before) => (before === undefined ? undefined : { incarnation: readSessionMetaCell(before).incarnation, owner }));
352
382
  }
353
383
  async touch(sessionId) {
354
384
  this.lastActivity.set(sessionId, new Date().toISOString());
@@ -371,10 +401,12 @@ export class LocalSessionStore {
371
401
  async listSessions(opts) {
372
402
  await this.ensureLoaded();
373
403
  await this.titles.ensureLoaded();
404
+ await this.meta.ensureLoaded();
374
405
  const metas = await this.repo.list();
375
406
  const all = [];
376
407
  for (const meta of metas) {
377
- const owner = this.owners.has(meta.id) ? (this.owners.get(meta.id) ?? null) : null;
408
+ const cell = this.meta.state.entries.get(meta.id);
409
+ const owner = cell === undefined ? null : (readSessionMetaCell(cell).owner ?? null);
378
410
  if (opts.owner !== undefined && owner !== opts.owner && !(opts.includeUnowned === true && owner === null))
379
411
  continue;
380
412
  const firstActivityAt = meta.createdAt || new Date(0).toISOString();
@@ -423,12 +455,11 @@ export class LocalSessionStore {
423
455
  }
424
456
  }
425
457
  async fork(sourceId, owner) {
426
- await this.incarnations.ensureLoaded();
458
+ await this.meta.ensureLoaded();
427
459
  try {
428
460
  const session = await this.repo.fork({ id: sourceId, createdAt: "" }, {});
429
461
  const id = (await session.getMetadata()).id;
430
- this.owners.set(id, owner);
431
- await this.mintIncarnation(id);
462
+ await this.writeMeta(id, () => ({ incarnation: mintSessionIncarnation(), owner }));
432
463
  return id;
433
464
  }
434
465
  catch (e) {
@@ -471,10 +502,9 @@ export class LocalSessionStore {
471
502
  async importEntriesAs(sessionId, owner, entries, incarnation) {
472
503
  if (!this.repo.importEntries)
473
504
  throw new SessionError("storage", "session import is not supported by this local backend");
474
- await this.incarnations.ensureLoaded();
505
+ await this.meta.ensureLoaded();
475
506
  await this.repo.importEntries(sessionId, owner ?? undefined, entries);
476
- this.owners.set(sessionId, owner);
477
- await this.writeIncarnation(sessionId, () => incarnation);
507
+ await this.writeMeta(sessionId, () => ({ incarnation, owner }));
478
508
  }
479
509
  async replaceEntries(sessionId, owner, entries) {
480
510
  if (!this.repo.importEntries)
@@ -482,8 +512,8 @@ export class LocalSessionStore {
482
512
  if (!this.repo.delete)
483
513
  throw new SessionError("storage", "session replace is not supported by this local backend (no delete seam)");
484
514
  validateEntriesForImport(entries);
485
- await this.incarnations.ensureLoaded();
486
- const prior = this.owners.has(sessionId) ? { owner: this.owners.get(sessionId) ?? null, incarnation: this.incarnations.state.entries.get(sessionId) } : undefined;
515
+ const current = await this.metaOf(sessionId);
516
+ const prior = current === undefined ? undefined : { owner: current.owner ?? null, incarnation: current.incarnation };
487
517
  await this.deleteSession(sessionId, owner);
488
518
  await this.importEntriesAs(sessionId, owner, entries, carriedIncarnation(prior, owner));
489
519
  }
@@ -499,8 +529,8 @@ export class LocalSessionStore {
499
529
  this.stagings.delete(stagingId);
500
530
  }
501
531
  async deleteSession(sessionId, owner) {
502
- const known = this.owners.has(sessionId);
503
- if (!known || !ownerEq(this.owners.get(sessionId) ?? null, owner))
532
+ const current = await this.ownerOf(sessionId);
533
+ if (current === undefined || !ownerEq(current, owner))
504
534
  return false;
505
535
  try {
506
536
  await this.repo.delete({ id: sessionId, createdAt: "" });
@@ -509,10 +539,10 @@ export class LocalSessionStore {
509
539
  if (!isNotFound(e))
510
540
  throw e;
511
541
  }
512
- this.owners.delete(sessionId);
542
+ await this.dropMeta(sessionId);
513
543
  this.lastActivity.delete(sessionId);
514
544
  this.lastTaskRun.delete(sessionId);
515
- for (const map of [this.titles, this.instructionSources, this.incarnations]) {
545
+ for (const map of [this.titles, this.instructionSources]) {
516
546
  await map.ensureLoaded();
517
547
  if (map.state.entries.delete(sessionId))
518
548
  await map.persist();
@@ -572,7 +602,7 @@ class LocalStagingHandle {
572
602
  await this.store.replaceEntries(this.realSessionId, owner, this.staged);
573
603
  }
574
604
  else {
575
- this.store.restampOwner(this.realSessionId, owner);
605
+ await this.store.restampOwner(this.realSessionId, owner);
576
606
  }
577
607
  this.store.forgetStaging(this.stagingId);
578
608
  return { relation: rel.relation };
@@ -65,6 +65,12 @@ export interface OneTimeMigrationWindow {
65
65
  * 而留着 M3 会让 M3 永远判「跑过了」。三条同版引入、同一天到期,本来就同一次删。
66
66
  */
67
67
  export declare const ONE_TIME_MIGRATIONS: readonly OneTimeMigrationWindow[];
68
+ /**
69
+ * 一条登记行的窗**此刻**开着没有(`today` = UTC 日历日 YYYY-MM-DD):未发布形(`removableAfter: null`)⇒ 开;否则今天不晚于 `removableAfter` ⇒ 开
70
+ * (窗关当天仍开,与到期门「今天严格晚于才红」同一条边界)。
71
+ * 只给**窗内才生效的行为臂**用(M10 的认领):那种臂过了窗就停,不等删码车 —— 删码因此是零行为变化,而窗只写在本表这一处。
72
+ */
73
+ export declare function migrationWindowOpen(window: Pick<OneTimeMigrationWindow, "removableAfter">, today: string): boolean;
68
74
  /** local 车道上 SQL 独有的四条(M1 / M2 / M4 / M5)的读数(M3 / M6 两车道都有,local 腿的读数由 `LocalBackend.runOneTimeMigrations` 现算):那几张表 / 列在文件底座上从来不存在 ⇒ 如实答 absent / 0。 */
69
75
  export declare const NO_SQL_MIGRATIONS: Omit<OneTimeMigrationReport, "askOriginRowsMigrated" | "holderDeathCodeRowsBackfilled">;
70
76
  /** 启动期的日志口(logger 的子集)。两级各有其用,见 {@link logOneTimeMigrations}。 */
@@ -119,7 +119,24 @@ export const ONE_TIME_MIGRATIONS = [
119
119
  "`decide.workflow_parks_unreadable`);出路不变(续跑发起它的那条 workflow run);随包排空判据没了,换钉前没有机器读数可数这些卡",
120
120
  reportKeys: ["workflowParkIndexKey", "workflowParkIndexRowsCarried"],
121
121
  },
122
+ {
123
+ id: "M10",
124
+ introducedIn: "7.119.0",
125
+ releasedOn: "2026-10-08",
126
+ removableAfter: "2026-11-07",
127
+ removal: "删 local 车道的认领臂:`LocalSessionStore.register` 里「属主从没被记下的存量会话 ⇒ 带 principal 的登记认领它」那一支、认领日志行 `session_owner_claimed`" +
128
+ "(连同店构造参数里只为它存在的 `log` 位)与读本表 M10 行的 `ownerClaimWindowOpen`;`security.ts` `createAuthorizer` 里对无主会话的那次认领 `register` 调用;" +
129
+ "`http/routes/sessions.ts` 409 `conflict.session_ownerless` 句里「Ways out: on the local backend, during the upgrade window …」那半句(连同冻结表 " +
130
+ "`test/api-error-text-freeze.test.ts` 那一行、契约码目录 `docs/ASSISTANT-WIRE-CONTRACT.md` 那一行);本文件的 `migrationWindowOpen`(若再无别的读者)",
131
+ afterRemoval: "没有响亮臂(删码本身零行为变化:窗关那一刻起认领臂已经不生效):属主从没被记下的存量会话(边档那一格只有化身 —— 跳过了带本条的版本直接升," +
132
+ "或窗内从没被带 principal 提交过)照旧答无主(`ownerOf` = null):它的会话规则这条路由读不了也改不了(409 `conflict.session_ownerless`)," +
133
+ "已存的规则对写下它们的 principal 的 run 照旧生效;出路 = 删会话(`DELETE /v1/sessions/:id`,规则随之删)",
134
+ reportKeys: [],
135
+ },
122
136
  ];
137
+ export function migrationWindowOpen(window, today) {
138
+ return window.removableAfter === null || today <= window.removableAfter;
139
+ }
123
140
  export const NO_SQL_MIGRATIONS = {
124
141
  retiredSnapshotTable: "absent",
125
142
  retiredSnapshotRows: 0,
@@ -533,7 +550,8 @@ export function logOneTimeMigrations(log, backend, report) {
533
550
  "run.orphaned; M7 writes nothing — it is the window of the refusal that keeps a suspended task whose stored allow list was written " +
534
551
  "under an earlier reading from resuming under the current one; M8: parked rows' allow-layer disclosure written by an earlier engine " +
535
552
  "under its old key shadowedRule rewritten to allowRuleShadowed; M9: the workflow park pointer index rebuilt with the key (session, run), " +
536
- "its rows carried over). Each step " +
553
+ "its rows carried over; M10 writes nothing at boot — on the local backend, while its window is open, the first task submitted with a principal " +
554
+ "on a session written by an earlier version (which recorded no owner) claims that session for the principal, one session_owner_claimed line per claim). Each step " +
537
555
  "runs on every boot until its window closes (the release that introduced it + 30 days; the per-step date is in " +
538
556
  "removableAfter, null while that release is not published yet) and is deleted in the first minor after that — start a " +
539
557
  "version that still carries a step at least once before upgrading past its deletion.",
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 对象存储配了快照桶却**不存在** = 部署错 —— boot 期核**一次**,缺 ⇒ 响亮拒启(不是每一拍 sweep 一条静默的 404)。
3
+ *
4
+ * ## 病
5
+ * `SESSION_SNAPSHOT_BUCKET`(缺省 `session-snapshots`)是 snapshot-BLOB offload 与 SendUserFile 私有 presigned 轨 / 沙箱 staging 的桶。server 不建桶
6
+ * (`docs/DEPLOY-PREREQS.md`「对象存储桶」),sema-up 的建桶清单又漏过它 ⇒ 起服绿,之后每一次对象写、每一拍孤儿 sweep 的列举(每分钟)都是
7
+ * 404 NoSuchBucket —— 运维读到的是一串「失败的请求」,不是「桶没建」。
8
+ *
9
+ * ## 形(一条规则,不加特例)
10
+ * · 核的是 {@link MinioBlobBackend.probeBucket}(presigned 列举 `max-keys=1`);**只有** 404 + `NoSuchBucket` 是「缺」,其余证明不了的形(连不上 / 403 / 5xx / 没有
11
+ * NoSuchBucket 的 404)⇒ **不拒**、照常起服,走登记过的 F 类兜底口 `recordFailOpen("server.snapshot-bucket.presence-unproven")`(计数 + 探针行 + 记录器的一次性 warn;
12
+ * 三问写在该 tag 的 note 里)—— 拒启要有证据,没有证据只留痕;
13
+ * · 缺 = 配置好的店资源还没就绪,与「库连不上」同一类处境:在 `STORE_LOCK_WAIT_MS` 窗内重试({@link openWithinWait},同一只窗、同一只节拍、同一条窗尾规则 —— 编排里
14
+ * 建桶晚于 server 起是常态),到窗时手上有缺桶的证据 ⇒ 拒启,拒句点名桶名、`MINIO_ENDPOINT` 的显示安全形(`buildDisplaySafeUrl` 的产物:userinfo 落地、
15
+ * query 值成记号,不带密钥)与出路;探针从头到尾没答过(每一次都挂到窗尾)⇒ 同样是「证明不了」,走上面那只兜底口、照常起服。
16
+ * · 只核**快照桶**(`config.snapshotBlobStore.bucket`;SendUserFile 的私桶缺省同一只);公桶 `sema-public` 是运维另配的匿名桶,不在本核之内。
17
+ */
18
+ import type { ServiceConfig } from "../config-types.js";
19
+ import { type StoreOpenWaitClock } from "./store-open-wait.js";
20
+ type SnapshotBlobStoreConfig = NonNullable<ServiceConfig["snapshotBlobStore"]>;
21
+ /** boot 期单次探针的上界(ms):黑洞端点(丢包不拒连)不许把起服拖到对象存储 IO 的 120s;到点 ⇒ `unknown`(证明不了缺桶 ⇒ 不拒、报一次)。 */
22
+ export declare const SNAPSHOT_BUCKET_PROBE_TIMEOUT_MS = 10000;
23
+ /** 缺桶的拒启句(唯一铸点)。 */
24
+ /** @param endpoint `MINIO_ENDPOINT` 的**显示安全形**(调用方过 {@link buildDisplaySafeUrl};本函数不碰原串)。 */
25
+ export declare function buildSnapshotBucketRefusal(bucket: string, endpoint: string, waitedMs: number): string;
26
+ /** 探针到窗尾一次都没答过时,兜底口 detail 里的成因(证明不了缺桶的一形)。 */
27
+ export declare const SNAPSHOT_BUCKET_PROBE_PENDING = "probe still pending when the window ended";
28
+ /**
29
+ * boot 期核一次快照桶(`boot/stores.ts`)。缺桶 ⇒ 窗内重试、到窗拒启;证明不了 ⇒ 兜底口留痕、照常起服。
30
+ * @param waitMs `config.storeLockWaitMs`。`0` ⇒ 只核一次(缺就当场拒)。
31
+ */
32
+ export declare function ensureSnapshotBucket(store: SnapshotBlobStoreConfig, waitMs: number, logger: {
33
+ warn(msg: string, meta?: Record<string, unknown>): void;
34
+ }, clock?: StoreOpenWaitClock): Promise<void>;
35
+ export {};
36
+ //# sourceMappingURL=snapshot-bucket-presence.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { buildDisplaySafeUrl } from "../trace/redact.js";
2
+ import { MinioBlobBackend } from "./blob-backend.js";
3
+ import { recordFailOpen } from "../observability/fail-open.js";
4
+ import { isStoreOpenWindowExpired, openWithinWait } from "./store-open-wait.js";
5
+ export const SNAPSHOT_BUCKET_PROBE_TIMEOUT_MS = 10_000;
6
+ class SnapshotBucketMissing extends Error {
7
+ constructor() {
8
+ super("snapshot bucket missing");
9
+ }
10
+ }
11
+ export function buildSnapshotBucketRefusal(bucket, endpoint, waitedMs) {
12
+ const waited = waitedMs > 0 ? `after retrying for ${waitedMs}ms (STORE_LOCK_WAIT_MS)` : "on the first check (STORE_LOCK_WAIT_MS=0: no wait)";
13
+ return (`Object storage is configured (MINIO_ENDPOINT → ${endpoint}) but the snapshot bucket "${bucket}" (SESSION_SNAPSHOT_BUCKET; default session-snapshots) does not exist there ${waited} — refusing to boot: ` +
14
+ `the server never creates buckets, so every snapshot-offload write and SendUserFile private-track request would answer 404 NoSuchBucket (and the orphan sweep would log one every minute, forever). ` +
15
+ `Next: create the bucket (mc mb <alias>/${bucket}; this version's sema-up scripts create it — re-run them on an existing sema-up deployment), or set SESSION_SNAPSHOT_BUCKET to a bucket that exists.`);
16
+ }
17
+ export const SNAPSHOT_BUCKET_PROBE_PENDING = "probe still pending when the window ended";
18
+ export async function ensureSnapshotBucket(store, waitMs, logger, clock) {
19
+ const probe = new MinioBlobBackend({ endpoint: store.endpoint, bucket: store.bucket, accessKey: store.accessKey, secretKey: store.secretKey, ...(store.region ? { region: store.region } : {}), ...(store.keyPrefix ? { keyPrefix: store.keyPrefix } : {}) });
20
+ let verdict;
21
+ try {
22
+ verdict = await openWithinWait(async () => {
23
+ const r = await probe.probeBucket(SNAPSHOT_BUCKET_PROBE_TIMEOUT_MS);
24
+ if (r.state === "missing")
25
+ throw new SnapshotBucketMissing();
26
+ return r;
27
+ }, waitMs, (w) => logger.warn("snapshot_bucket_wait", { bucket: store.bucket, endpoint: buildDisplaySafeUrl(store.endpoint), waitMs: w.waitMs, error: w.error }), clock);
28
+ }
29
+ catch (err) {
30
+ if (err instanceof SnapshotBucketMissing)
31
+ throw new Error(buildSnapshotBucketRefusal(store.bucket, buildDisplaySafeUrl(store.endpoint), waitMs), { cause: err });
32
+ if (!isStoreOpenWindowExpired(err))
33
+ throw err;
34
+ verdict = { state: "unknown", detail: SNAPSHOT_BUCKET_PROBE_PENDING };
35
+ }
36
+ if (verdict.state === "unknown") {
37
+ recordFailOpen("server.snapshot-bucket.presence-unproven", `bucket=${store.bucket} endpoint=${buildDisplaySafeUrl(store.endpoint)} ${verdict.detail}`);
38
+ }
39
+ }
40
+ //# sourceMappingURL=snapshot-bucket-presence.js.map
@@ -398,8 +398,11 @@ export declare function createStoreBackend(config: ServiceConfig, onWarn?: BootL
398
398
  * 而 LocalBackend 的 `ensureSchema` 是**空实现**,真正会抛的 mkdir + 数据根 BootLock 在构造函数里、
399
399
  * 落在 try 之外。于是那条降级臂对 local 形从来没生效过,注释宣称的行为与代码真做的事撕裂。
400
400
  *
401
- * 降级条件不变(两形同一姿势):`sessionBackend=auto`(配置的 DB 不可达,S5 原形)或**默认推导**的
402
- * local(`dbBackend=local && !dbBackendExplicit`)。**显式** DB_BACKEND=local / tidb 仍 fail-loud——
401
+ * **降级臂只有一条:缺省推导的 local**(`dbBackend=local && !dbBackendExplicit`)。🔴 **SQL 族没有降级臂** —— 配了 DSN 就是要这只库:
402
+ * `SESSION_BACKEND=auto` 与显式 `DB_BACKEND=mysql|pg` 同一待遇,库当下打不开 ⇒ 在 `STORE_LOCK_WAIT_MS` 的窗内重试
403
+ * ({@link openWithinWait}),到窗仍打不开 ⇒ 拒启(拒句 {@link buildSqlStoreOpenRefusal} 点名后端与库的显示安全形)。修前 `auto` 在这里
404
+ * 静默落内存(只留一行 warn):会话 / 停驻 / run 账本进进程内存、重启即丢,而 `/health` 照样 200 —— [ref] P 类 fail-open。
405
+ * 零 DSN 的内存是配置说的(`config.ts` 的 `auto` 在无坐标时就不探测),不经过本函数。**显式** DB_BACKEND=local 仍 fail-loud——
403
406
  * operator 要了 durable,静默丢=丢数据。 */
404
407
  export declare function openStoreBackendWithFallback(config: ServiceConfig, logger: {
405
408
  warn: (msg: string, meta?: Record<string, unknown>) => void;
@@ -409,9 +412,17 @@ export declare function openStoreBackendWithFallback(config: ServiceConfig, logg
409
412
  degradedReason: StoreDegradedReason | undefined;
410
413
  }>;
411
414
  /**
412
- * S-866 ⑤c / G4 —— SQL 族「显式要了持久而店打不开」的拒启句(唯一铸点)。点名 `DB_BACKEND=<引擎>`,给三条出路:修坐标 / 连通性;
413
- * `SESSION_BACKEND=auto`(尽力而为:店不可达时以内存起服,什么都不持久);`DB_BACKEND=memory`(显式内存部署)。驱动原文只取 message
414
- * (与修前 `fatalExit` 打出的同一句),凭据不在其中(驱动不回显口令)。
415
+ * 已配置的 SQL 库坐标 → 一条只含 `scheme://host:port` 的 URL 串,**交给**配置型 URL 的唯一显示铸点 {@link buildDisplaySafeUrl}
416
+ * (出口处写 `buildDisplaySafeUrl(sqlStoreCoordinatesAsUrl(config))`)。它**不是**一份显示投影:显示形只有铸点一只;这里只做「结构化坐标 → URL 串」的拼装
417
+ * (`config.tidb` / `config.pg` 本来就是结构化坐标,没有 DSN 字符串),user / password / database 一概不进产物。
418
+ * host 过不了闭字符集(拼进 URL 会把 `/` `@` `?` 之类带成别的成分)或无坐标 ⇒ 交出一个不是 URL 的串,铸点答它的占位 `«redacted:url»`。
419
+ * scheme 只是引擎名(`postgres` / `mysql`),不对应任何连接。
415
420
  */
416
- export declare function buildSqlStoreOpenRefusal(engine: "mysql" | "pg", err: unknown): string;
421
+ export declare function sqlStoreCoordinatesAsUrl(config: Pick<ServiceConfig, "dbBackend" | "tidb" | "pg">): string;
422
+ /**
423
+ * SQL 族「配了 DSN 而店打不开(窗内重试过了)」的拒启句(唯一铸点)。点名 `DB_BACKEND=<引擎>`、库的显示安全形(`postgres://host:port` / `mysql://host:port`,经 {@link sqlStoreCoordinatesAsUrl} 拼出、再过 {@link buildDisplaySafeUrl},
424
+ * 不带口令)、等了多久,出路两条:修坐标 / 连通性(窗是 `STORE_LOCK_WAIT_MS`);或**显式**内存部署(`DB_BACKEND=memory`)。🔴 没有第三条
425
+ * 「`SESSION_BACKEND=auto` 内存起服」—— 那一条就是被删掉的静默降级。驱动原文只取 message(与修前 `fatalExit` 打出的同一句),凭据不在其中(驱动不回显口令)。
426
+ */
427
+ export declare function buildSqlStoreOpenRefusal(engine: "mysql" | "pg", endpoint: string, waitedMs: number, err: unknown): string;
417
428
  //# sourceMappingURL=store-backend.d.ts.map