@sema-agent/server 7.149.0 → 7.150.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 (194) hide show
  1. package/MIGRATION.md +122 -9
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +39 -15
  5. package/dist/approval-card.d.ts +1 -1
  6. package/dist/approval-gate-currency.d.ts +1 -1
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +2 -2
  9. package/dist/background-session-reap.d.ts +1 -1
  10. package/dist/boot/deferred-sandbox-path-env.d.ts +5 -3
  11. package/dist/boot/deferred-sandbox-path-env.js +1 -2
  12. package/dist/boot/parked-revive-gate.d.ts +5 -5
  13. package/dist/boot/parked-revive-gate.js +2 -3
  14. package/dist/boot/resolve-spec.d.ts +4 -5
  15. package/dist/boot/resolve-spec.js +61 -25
  16. package/dist/boot/retention-lane.d.ts +1 -1
  17. package/dist/boot/session-faces.js +21 -21
  18. package/dist/boot/stage-03-store-backend.js +2 -2
  19. package/dist/boot/stage-06-runners.js +1 -2
  20. package/dist/boot/stage-07-capability-layer.js +1 -1
  21. package/dist/boot/stage-10-http-server.d.ts +33 -1
  22. package/dist/boot/stage-10-http-server.js +9 -4
  23. package/dist/boot/web-admin.d.ts +1 -1
  24. package/dist/capability-facts.d.ts +65 -56
  25. package/dist/capability-facts.js +42 -35
  26. package/dist/config-catalog.js +1 -1
  27. package/dist/config-types.d.ts +12 -11
  28. package/dist/deployment-governance.d.ts +7 -28
  29. package/dist/deployment-governance.js +1 -14
  30. package/dist/device-ws-hub.d.ts +1 -0
  31. package/dist/device-ws-hub.js +64 -28
  32. package/dist/device-ws-protocol.d.ts +20 -6
  33. package/dist/device-ws-protocol.js +9 -2
  34. package/dist/engine-identity.d.ts +7 -1
  35. package/dist/engine-identity.js +5 -0
  36. package/dist/field-conflict.d.ts +2 -2
  37. package/dist/fleet/engine-signal.d.ts +14 -1
  38. package/dist/fleet/engine-signal.js +5 -1
  39. package/dist/fleet/fleet-reconciler.d.ts +1 -1
  40. package/dist/fleet/serving-lease.d.ts +18 -3
  41. package/dist/fleet/serving-lease.js +81 -35
  42. package/dist/fleet-client.d.ts +13 -11
  43. package/dist/fleet-client.js +53 -33
  44. package/dist/history-anchor.js +1 -1
  45. package/dist/hitl-delivery.d.ts +1 -3
  46. package/dist/hitl-delivery.js +1 -1
  47. package/dist/http/active-run-conflict.d.ts +3 -7
  48. package/dist/http/active-run-conflict.js +2 -2
  49. package/dist/http/admission.js +2 -2
  50. package/dist/http/approval-sweeps.js +2 -1
  51. package/dist/http/caller-trust.d.ts +2 -2
  52. package/dist/http/caller-trust.js +3 -3
  53. package/dist/http/checkpoint-ctx.d.ts +18 -63
  54. package/dist/http/checkpoint-ctx.js +1 -24
  55. package/dist/http/claim-heartbeat.d.ts +4 -4
  56. package/dist/http/claim-heartbeat.js +3 -3
  57. package/dist/http/dispatch.js +2 -2
  58. package/dist/http/durable-run-leg.js +4 -4
  59. package/dist/http/owner-access.d.ts +243 -19
  60. package/dist/http/owner-access.js +184 -5
  61. package/dist/http/readiness.d.ts +4 -2
  62. package/dist/http/resume-ctx-read.js +16 -1
  63. package/dist/http/resume-legs.js +18 -14
  64. package/dist/http/route-ctx.d.ts +23 -11
  65. package/dist/http/routes/a2a-serve.js +15 -14
  66. package/dist/http/routes/approvals-assistant.d.ts +3 -2
  67. package/dist/http/routes/approvals-assistant.js +8 -9
  68. package/dist/http/routes/capabilities.js +4 -3
  69. package/dist/http/routes/config-catalog.d.ts +1 -1
  70. package/dist/http/routes/mcp-reconnect.js +2 -2
  71. package/dist/http/routes/memory-origin.d.ts +1 -1
  72. package/dist/http/routes/notify-wake.js +2 -3
  73. package/dist/http/routes/projects.d.ts +1 -0
  74. package/dist/http/routes/projects.js +5 -3
  75. package/dist/http/routes/retention-ops.d.ts +1 -1
  76. package/dist/http/routes/run-permission-mode.d.ts +1 -1
  77. package/dist/http/routes/run-permission-mode.js +2 -2
  78. package/dist/http/routes/runs.d.ts +1 -1
  79. package/dist/http/routes/runs.js +23 -30
  80. package/dist/http/routes/session-memory-sync.js +4 -5
  81. package/dist/http/routes/session-sync.js +3 -7
  82. package/dist/http/routes/sessions.d.ts +1 -1
  83. package/dist/http/routes/sessions.js +18 -44
  84. package/dist/http/routes/tasks.js +9 -8
  85. package/dist/http/routes/trace-usage.js +14 -14
  86. package/dist/http/routes/workflows.d.ts +1 -0
  87. package/dist/http/routes/workflows.js +20 -38
  88. package/dist/http/server.d.ts +8 -4
  89. package/dist/http/server.js +5 -1
  90. package/dist/http/stored-body-replay.d.ts +3 -24
  91. package/dist/http/stored-body-replay.js +2 -12
  92. package/dist/http/submit-ctx.d.ts +1 -2
  93. package/dist/http/submit-ctx.js +3 -2
  94. package/dist/http/submit-replay.d.ts +15 -2
  95. package/dist/http/submit-replay.js +10 -4
  96. package/dist/http/wire-types.d.ts +6 -3
  97. package/dist/leader/endpoint.js +9 -8
  98. package/dist/leg-facts.d.ts +8 -2
  99. package/dist/leg-facts.js +1 -0
  100. package/dist/mcp-authorization-face.d.ts +18 -14
  101. package/dist/mcp-authorization-face.js +3 -2
  102. package/dist/memory-store-refusal.d.ts +1 -1
  103. package/dist/observability/corrupt-read-seat.d.ts +1 -1
  104. package/dist/observability/fail-open.d.ts +5 -5
  105. package/dist/observability/fail-open.js +5 -5
  106. package/dist/observability/logger.d.ts +7 -0
  107. package/dist/observability/logger.js +3 -1
  108. package/dist/observability/run-terminal.d.ts +1 -1
  109. package/dist/owner-faces.d.ts +27 -3
  110. package/dist/owner-faces.js +2 -0
  111. package/dist/peer-seat.d.ts +1 -1
  112. package/dist/peer-seat.js +52 -59
  113. package/dist/plugins/exec-path-law.d.ts +34 -0
  114. package/dist/plugins/exec-path-law.js +13 -0
  115. package/dist/plugins/file-run-store.js +1 -1
  116. package/dist/plugins/local-checkpoint-store.d.ts +4 -1
  117. package/dist/plugins/local-checkpoint-store.js +3 -0
  118. package/dist/plugins/memory-run-store.js +1 -1
  119. package/dist/plugins/one-time-migrations.d.ts +74 -49
  120. package/dist/plugins/one-time-migrations.js +134 -69
  121. package/dist/plugins/pg-pool.js +4 -0
  122. package/dist/plugins/posix-shell-fs.js +4 -2
  123. package/dist/plugins/remote-env-adb.js +3 -2
  124. package/dist/plugins/remote-env-device.js +8 -6
  125. package/dist/plugins/remote-env-e2b.d.ts +3 -1
  126. package/dist/plugins/remote-env-e2b.js +11 -8
  127. package/dist/plugins/remote-env-host.d.ts +1 -1
  128. package/dist/plugins/remote-env-host.js +20 -11
  129. package/dist/plugins/remote-env-k8s.js +3 -2
  130. package/dist/plugins/remote-env-local-docker.js +3 -2
  131. package/dist/plugins/remote-env-ssh.js +4 -3
  132. package/dist/plugins/remote-shell.d.ts +3 -1
  133. package/dist/plugins/remote-shell.js +3 -3
  134. package/dist/plugins/replica-registration.d.ts +35 -0
  135. package/dist/plugins/replica-registration.js +48 -0
  136. package/dist/plugins/retention-lane-store-sql.d.ts +1 -1
  137. package/dist/plugins/run-store-sql.d.ts +1 -1
  138. package/dist/plugins/run-store-sql.js +2 -2
  139. package/dist/plugins/scenario-prefix-switch-m42.d.ts +6 -12
  140. package/dist/plugins/scenario-prefix-switch-m42.js +7 -5
  141. package/dist/plugins/schema-consistency.d.ts +7 -5
  142. package/dist/plugins/schema-consistency.js +29 -16
  143. package/dist/plugins/schema-drift-sentence.js +1 -1
  144. package/dist/plugins/serving-requirement.d.ts +49 -0
  145. package/dist/plugins/serving-requirement.js +53 -0
  146. package/dist/plugins/stop-then-start-sentence.d.ts +42 -3
  147. package/dist/plugins/stop-then-start-sentence.js +44 -0
  148. package/dist/plugins/store-backend.d.ts +1 -1
  149. package/dist/plugins/store-contracts.d.ts +12 -2
  150. package/dist/plugins/store-contracts.js +3 -0
  151. package/dist/plugins/tidb-pool.js +4 -0
  152. package/dist/process-instance.d.ts +3 -0
  153. package/dist/process-instance.js +7 -0
  154. package/dist/project-context.d.ts +66 -0
  155. package/dist/project-context.js +70 -0
  156. package/dist/question.d.ts +9 -21
  157. package/dist/question.js +28 -19
  158. package/dist/run-cancel-card.d.ts +1 -1
  159. package/dist/run-cancel-card.js +2 -1
  160. package/dist/security.d.ts +1 -1
  161. package/dist/session-agent.d.ts +1 -10
  162. package/dist/session-agent.js +0 -6
  163. package/dist/session-incarnation.d.ts +18 -4
  164. package/dist/session-incarnation.js +4 -1
  165. package/dist/session-notify-door.d.ts +4 -4
  166. package/dist/session-notify-door.js +5 -6
  167. package/dist/session-rules-seat.d.ts +4 -3
  168. package/dist/session-rules-seat.js +3 -11
  169. package/dist/session-sync.js +3 -3
  170. package/dist/stores-sql/checkpoint-store-sql.d.ts +21 -2
  171. package/dist/stores-sql/checkpoint-store-sql.js +106 -18
  172. package/dist/stores-sql/retention-store-sql.d.ts +1 -1
  173. package/dist/task-cwd.d.ts +4 -3
  174. package/dist/task-settings.d.ts +13 -0
  175. package/dist/task-settings.js +13 -7
  176. package/dist/tool-approval.d.ts +7 -6
  177. package/dist/tool-approval.js +41 -37
  178. package/dist/trace/engine-notice-wire.d.ts +14 -0
  179. package/dist/trace/engine-notice-wire.js +18 -1
  180. package/dist/trace/project.d.ts +4 -3
  181. package/dist/trace/project.js +3 -2
  182. package/dist/trace/projection-table.d.ts +33 -2
  183. package/dist/trace/self-minted-wire.d.ts +150 -17
  184. package/dist/trace/self-minted-wire.js +54 -16
  185. package/dist/trace/tool-card.d.ts +3 -0
  186. package/dist/trace/tool-card.js +24 -0
  187. package/dist/trace/tool-output-media.d.ts +2 -1
  188. package/dist/unattended-permission-mode.d.ts +1 -1
  189. package/dist/web-admin-client/apply-effective.d.ts +1 -1
  190. package/dist/web-admin-client/effective-keys.d.ts +1 -1
  191. package/dist/web-admin-client/skills-mcp.d.ts +1 -1
  192. package/docs/DEVICE-EXECUTOR-PROTOCOL.md +57 -29
  193. package/package.json +2 -2
  194. package/wire-changes/server@7.150.0.json +335 -0
@@ -2,6 +2,7 @@ import { writeLogText } from "../observability/logger.js";
2
2
  import { spawn } from "node:child_process";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { anchoredPath, anchorUnder, joinedPath } from "./exec-path-law.js";
5
6
  import fs from "node:fs/promises";
6
7
  import { randomBytes } from "node:crypto";
7
8
  import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync, accessSync, constants as FS_CONST } from "node:fs";
@@ -12,7 +13,7 @@ import { buildCapturedOutput, createCutError } from "../exec-cut.js";
12
13
  import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
13
14
  import { IS_WIN32, resolveHostShell, hostShell, pinHostShell, spawnGroupOptions, killTreeHard, killTreeSoft, hardKillGroupNow, collapseWin32EnvKeys, adoptHostForegroundChild } from "./host-platform.js";
14
15
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
15
- import { FileError, NodeExecutionEnv, inheritEnvPolicyProblem, ExecutionError, RemoteExecutionError, RollingTailBuffer, walkSymlinkChain, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
16
+ import { FileError, NodeExecutionEnv, inheritEnvPolicyProblem, ExecutionError, RemoteExecutionError, RollingTailBuffer, walkSymlinkChain, markTruncated, SchedulerError, BackgroundShellError, physicalPathOf as kernelRealPathOf, } from "@sema-agent/core";
16
17
  import { capabilitiesFor } from "../execution-lane-caps.js";
17
18
  import { DEPLOYMENT_SHAPE_PREDICATES } from "../deployment-shape.js";
18
19
  import { LaneHomeSeat, laneHomeRefusal } from "../lane-home-probe.js";
@@ -1004,7 +1005,7 @@ export class RemoteHostExecutionEnv {
1004
1005
  return ok(this.resolve(p));
1005
1006
  }
1006
1007
  async joinPath(parts) {
1007
- return ok(path.join(...parts));
1008
+ return ok(joinedPath(path, parts));
1008
1009
  }
1009
1010
  async readTextFile(p, abortSignal) {
1010
1011
  const ready = await this.fsReady(p);
@@ -1106,7 +1107,7 @@ export class RemoteHostExecutionEnv {
1106
1107
  const entries = await fs.readdir(abs, { withFileTypes: true });
1107
1108
  const out = [];
1108
1109
  for (const e of entries) {
1109
- const child = path.join(abs, e.name);
1110
+ const child = anchorUnder(path, abs, e.name);
1110
1111
  let size = 0;
1111
1112
  let mtimeMs = 0;
1112
1113
  try {
@@ -1247,7 +1248,7 @@ export class RemoteHostExecutionEnv {
1247
1248
  });
1248
1249
  }
1249
1250
  resolve(p) {
1250
- return path.isAbsolute(p) ? path.normalize(p) : path.normalize(path.resolve(this.cwd, p));
1251
+ return anchoredPath(path, this.cwd, p);
1251
1252
  }
1252
1253
  assertWorkspaceParentWritable() {
1253
1254
  const uid = process.getuid?.();
@@ -1324,14 +1325,22 @@ export class RemoteHostExecutionEnv {
1324
1325
  const abs = this.resolve(p);
1325
1326
  if (!existsSync(abs))
1326
1327
  return false;
1327
- const real = (x) => { try {
1328
- return realpathSync(x);
1329
- }
1330
- catch {
1331
- return path.resolve(x);
1332
- } };
1328
+ const real = (x) => {
1329
+ try {
1330
+ return kernelRealPathOf(x);
1331
+ }
1332
+ catch {
1333
+ return undefined;
1334
+ }
1335
+ };
1336
+ const target = real(abs);
1337
+ if (target === undefined)
1338
+ return false;
1333
1339
  const inside = (root) => {
1334
- const rel = path.relative(real(root), real(abs));
1340
+ const r = real(root);
1341
+ if (r === undefined)
1342
+ return false;
1343
+ const rel = path.relative(r, target);
1335
1344
  return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
1336
1345
  };
1337
1346
  return (this.spoolRootDir !== undefined && inside(this.spoolRootDir)) || (this.tenure.kind === "borrowed" && inside(this.workspaceDir));
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { anchoredPath, joinedPath } from "./exec-path-law.js";
2
3
  import { readFileSync } from "node:fs";
3
4
  import https from "node:https";
4
5
  import http from "node:http";
@@ -300,7 +301,7 @@ export class RemoteK8sExecutionEnv {
300
301
  return this.cfg.timeoutMs;
301
302
  }
302
303
  resolve(p) {
303
- return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
304
+ return anchoredPath(path.posix, this.cwd, p);
304
305
  }
305
306
  workspaceHandle() {
306
307
  return { sandboxId: this.podName ?? "(unconnected)", provider: PROVIDER, mountPath: this.cwd, ...(this.handleSnapshotId ? { snapshotId: this.handleSnapshotId } : {}) };
@@ -2196,7 +2197,7 @@ export class RemoteK8sExecutionEnv {
2196
2197
  return ok(this.resolve(p));
2197
2198
  }
2198
2199
  async joinPath(parts) {
2199
- return ok(path.posix.join(...parts));
2200
+ return ok(joinedPath(path.posix, parts));
2200
2201
  }
2201
2202
  async readBinaryFile(p, abortSignal) {
2202
2203
  const abs = this.resolve(p);
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { anchoredPath, joinedPath } from "./exec-path-law.js";
2
3
  import { spawn } from "node:child_process";
3
4
  import { hostSubprocessEnv } from "../server-secret-env.js";
4
5
  import { LANE_HOME_PROBE_COMMAND, LaneHomeSeat } from "../lane-home-probe.js";
@@ -89,7 +90,7 @@ export class RemoteLocalDockerExecutionEnv {
89
90
  return this.teardownSettled && this.leftover === undefined;
90
91
  }
91
92
  resolve(p) {
92
- return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
93
+ return anchoredPath(path.posix, this.cwd, p);
93
94
  }
94
95
  workspaceHandle() {
95
96
  return this.handle ?? { sandboxId: "(unconnected)", provider: PROVIDER, mountPath: this.cfg.mountPath };
@@ -618,7 +619,7 @@ export class RemoteLocalDockerExecutionEnv {
618
619
  return ok(this.resolve(p));
619
620
  }
620
621
  async joinPath(parts) {
621
- return ok(path.posix.join(...parts));
622
+ return ok(joinedPath(path.posix, parts));
622
623
  }
623
624
  async readBinaryFile(p, abortSignal) {
624
625
  const ready = await this.fsReady(p, abortSignal);
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { anchoredPath, anchorUnder, joinedPath } from "./exec-path-law.js";
2
3
  import { Client } from "ssh2";
3
4
  import { createSshHostKeyVerifier } from "../ssh-host-key.js";
4
5
  import { appendFileStaged, buildCwdEnvCommand, createCwdGuardedCall, kindFromMode, writeFileStaged } from "./remote-shell.js";
@@ -57,7 +58,7 @@ export class RemoteSshExecutionEnv {
57
58
  this.homeSeat = new LaneHomeSeat(PROVIDER, config);
58
59
  }
59
60
  resolve(p) {
60
- return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
61
+ return anchoredPath(path.posix, this.cwd, p);
61
62
  }
62
63
  workspaceHandle() {
63
64
  return { sandboxId: `${this.cfg.host}:${this.cfg.port}`, provider: PROVIDER, mountPath: this.cwd };
@@ -595,7 +596,7 @@ export class RemoteSshExecutionEnv {
595
596
  return ok(this.resolve(p));
596
597
  }
597
598
  async joinPath(parts) {
598
- return ok(path.posix.join(...parts));
599
+ return ok(joinedPath(path.posix, parts));
599
600
  }
600
601
  async readTextFile(p, abortSignal) {
601
602
  const r = await this.readBinaryFile(p, abortSignal);
@@ -674,7 +675,7 @@ export class RemoteSshExecutionEnv {
674
675
  const entries = await new Promise((resolve, reject) => sftp.readdir(abs, (err, list) => (err ? reject(err) : resolve(list))));
675
676
  return ok(entries.map((e) => {
676
677
  const a = e.attrs;
677
- return { name: e.filename, path: path.posix.join(abs, e.filename), kind: kindFromMode(a.mode), size: a.size ?? 0, mtimeMs: (a.mtime ?? 0) * 1000 };
678
+ return { name: e.filename, path: anchorUnder(path.posix, abs, e.filename), kind: kindFromMode(a.mode), size: a.size ?? 0, mtimeMs: (a.mtime ?? 0) * 1000 };
678
679
  }));
679
680
  }
680
681
  catch (e) {
@@ -60,7 +60,9 @@ export type CwdGuardFace = "settled" | "raw";
60
60
  * 🔴 [ref](7.103.1)—— 三条远端 shell 腿(ssh / k8s / adb)交给远端 `sh -c` 的那一串**唯一**的组装:进工作目录 →(settled 面)打 started 行 →
61
61
  * 导出任务 env → 跑命令。各腿只决定 `dir`(自己的缺省 cwd 规则 + 路径绝对化),串的形状只在这里。
62
62
  *
63
- * 形:`cd '<dir>' || { echo '<marker>: <dir> (cd failed; command not run)' >&2; exit 125; }; echo '<started>' >&2; export K='v'; … <command>`
63
+ * 形:`cd -P '<dir>' || { echo '<marker>: <dir> (cd failed; command not run)' >&2; exit 125; }; echo '<started>' >&2; export K='v'; … <command>`
64
+ * · `-P`(物理):工作目录按内核解 —— 执行缝路径律下 `<dir>` 是未折的拼法(`<base>/bridge/../next`),文件面由内核解 `..`;缺省的逻辑 `cd` 会先词法折成 `<base>/next`,
65
+ * 命令与文件动作就各在一只目录。`-P` 是 POSIX `cd` 的选项(dash / bash / busybox ash / mksh 都认)。
64
66
  * (raw 面没有 `echo '<started>' >&2;` 那一段)
65
67
  * · cd 失败(目录不存在 / 无权限 / 不是目录)⇒ 命令**不跑**,整条退 {@link CWD_UNAVAILABLE_EXIT};stderr 先是 shell 自己的 cd 报错,再是守卫行。
66
68
  * 修前形 `cd '<dir>' && export K='v'; <command>`:`&&` 只绑到第一只 `export`,`;` 之后的命令照样在**登录目录**里跑,还少了那只 env ——
@@ -30,7 +30,7 @@ export function buildCwdEnvCommand(command, dir, env, face) {
30
30
  const nonce = randomBytes(8).toString("hex");
31
31
  cwdGuard = { dir, marker: `${CWD_UNAVAILABLE_LINE} [${nonce}]`, started: startedLineFor(face, nonce) };
32
32
  const line = `${cwdGuard.marker}: ${dir} (cd failed; command not run)`;
33
- parts.push(`cd ${shellQuote(dir)} || { echo ${shellQuote(line)} >&2; exit ${CWD_UNAVAILABLE_EXIT}; };`);
33
+ parts.push(`cd -P ${shellQuote(dir)} || { echo ${shellQuote(line)} >&2; exit ${CWD_UNAVAILABLE_EXIT}; };`);
34
34
  if (cwdGuard.started !== undefined)
35
35
  parts.push(`echo ${shellQuote(cwdGuard.started)} >&2;`);
36
36
  }
@@ -184,11 +184,11 @@ function hardenPrivate(dir) {
184
184
  return `( chmod -- 700 ${dir} 2>/dev/null && ${readModeOwner(dir)} && case "$1" in drw[xs]--[-S]--[-T]*) [ "$3" = "$(id -u)" ];; *) false;; esac && ${aclCleared(dir)} )`;
185
185
  }
186
186
  function aclCleared(dir) {
187
- const marker = `ls_out=$(cd ${dir} && ls -ld . 2>&1) && [ -z "$(printf '%s\\n' "$ls_out" | sed -n 2p)" ] && mode_word=\${ls_out%% *} && ` +
187
+ const marker = `ls_out=$(cd -P ${dir} && ls -ld . 2>&1) && [ -z "$(printf '%s\\n' "$ls_out" | sed -n 2p)" ] && mode_word=\${ls_out%% *} && ` +
188
188
  `case "$mode_word" in d?????????*+*) echo +;; d?????????*) ;; *) false;; esac`;
189
189
  return (`plat=$(uname -s 2>/dev/null) && case "$plat" in ` +
190
190
  `Linux) acl_read() { :; }; acl_strip() { false; };; ` +
191
- `Darwin) acl_read() { ls_out=$(cd ${dir} && ls -led . 2>&1) && case "$ls_out" in d?????????*) ;; *) false;; esac && printf '%s\\n' "$ls_out" | sed 1d; }; acl_strip() { chmod -N ${dir}; };; ` +
191
+ `Darwin) acl_read() { ls_out=$(cd -P ${dir} && ls -led . 2>&1) && case "$ls_out" in d?????????*) ;; *) false;; esac && printf '%s\\n' "$ls_out" | sed 1d; }; acl_strip() { chmod -N ${dir}; };; ` +
192
192
  `FreeBSD) acl_read() { ${marker}; }; acl_strip() { setfacl -b ${dir}; };; ` +
193
193
  `SunOS) acl_read() { ${marker}; }; acl_strip() { chmod A- ${dir}; };; ` +
194
194
  `*) false;; esac && acl=$(acl_read 2>/dev/null) && { [ -z "$acl" ] || { acl_strip 2>/dev/null && acl=$(acl_read 2>/dev/null) && [ -z "$acl" ]; }; }`);
@@ -0,0 +1,35 @@
1
+ import type { SqlDialect, SqlExec } from "./sql-driver.js";
2
+ /** 表名。 */
3
+ export declare const REPLICA_REGISTRATION_TABLE = "replica_registration";
4
+ /**
5
+ * 本构建的能力名单(常设、只增):每一条读停-迁-起证据的一次性迁移一个名。删码之后名字留着 —— 名单只增不减(钉:`test/replica-registration.test.ts`)。
6
+ * 守卫读证据时传的名在型上就要在这里(`ReplicaCapability`):不在名单里的迁移读不了证据,编译期红。
7
+ */
8
+ export declare const REPLICA_CAPABILITIES: readonly ["M40", "M41", "M42", "M43", "M51"];
9
+ export type ReplicaCapability = (typeof REPLICA_CAPABILITIES)[number];
10
+ /** 留存视界(写者钟):登记早于此的别人的行在下一次登记时删。量级取远大于任一副本的寿命与副本间钟差。 */
11
+ export declare const REPLICA_REGISTRATION_HORIZON_MS: number;
12
+ /** 登记行(纯数据)。 */
13
+ export interface ReplicaRegistration {
14
+ readonly instanceId: string;
15
+ /** 发布线(`package.json` 的版本串;只作归因,不进任何判据)。 */
16
+ readonly releaseLine: string;
17
+ readonly capabilities: readonly string[];
18
+ readonly hostName: string;
19
+ readonly pid: number;
20
+ }
21
+ /** 本进程的登记行:身份 · 本构建的发布线 · 能力名单 · 主机 · pid。 */
22
+ export declare function buildThisReplicaRegistration(): ReplicaRegistration;
23
+ /** 能力格的存形:逗号连接。 */
24
+ export declare function capabilitiesCell(capabilities: readonly string[]): string;
25
+ /** `knows(r, M)`:能力名单里有 M(整词相等;读不出的格 ⇒ 不知道,保守)。观察与准入共用这一只判据。 */
26
+ export declare function knowsCapability(capabilities: readonly string[], migrationId: string): boolean;
27
+ /** 能力格的读形(存形的逆;空串 ⇒ 空名单)。 */
28
+ export declare function capabilitiesOfCell(cell: unknown): readonly string[];
29
+ /** 建表句(两方言;幂等)。中央建表语句读它(`tidb-pool.ts` / `pg-pool.ts`),与 `one-time-migration-marker.ts` 同形:键列钉二进制排序。 */
30
+ export declare function replicaRegistrationDdl(dialect: SqlDialect): string;
31
+ /** 登记:先删视界外的别人的行,再缺席才落本行(两句,各自自动提交)。 */
32
+ export declare function registerReplica(db: SqlExec, dialect: SqlDialect, me: ReplicaRegistration, nowMs: number): Promise<void>;
33
+ /** 读这几只身份的登记行(不在的身份不出现在结果里;空入参 ⇒ 不读库)。 */
34
+ export declare function readReplicaRegistrations(db: SqlExec, dialect: SqlDialect, instanceIds: readonly string[]): Promise<Map<string, ReplicaRegistration>>;
35
+ //# sourceMappingURL=replica-registration.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { hostname } from "node:os";
2
+ import { processInstanceId } from "../process-instance.js";
3
+ import { serviceVersion } from "../service-version.js";
4
+ export const REPLICA_REGISTRATION_TABLE = "replica_registration";
5
+ export const REPLICA_CAPABILITIES = ["M40", "M41", "M42", "M43", "M51"];
6
+ export const REPLICA_REGISTRATION_HORIZON_MS = 90 * 24 * 60 * 60 * 1000;
7
+ export function buildThisReplicaRegistration() {
8
+ return { instanceId: processInstanceId(), releaseLine: serviceVersion(), capabilities: REPLICA_CAPABILITIES, hostName: hostname(), pid: process.pid };
9
+ }
10
+ export function capabilitiesCell(capabilities) {
11
+ return capabilities.join(",");
12
+ }
13
+ export function knowsCapability(capabilities, migrationId) {
14
+ return capabilities.includes(migrationId);
15
+ }
16
+ export function capabilitiesOfCell(cell) {
17
+ return typeof cell === "string" && cell !== "" ? cell.split(",") : [];
18
+ }
19
+ export function replicaRegistrationDdl(dialect) {
20
+ return dialect === "mysql"
21
+ ? `CREATE TABLE IF NOT EXISTS ${REPLICA_REGISTRATION_TABLE} (instance_id VARCHAR(64) NOT NULL PRIMARY KEY, release_line VARCHAR(64) NOT NULL, capabilities VARCHAR(1024) NOT NULL, host_name VARCHAR(255) NOT NULL, pid BIGINT NOT NULL, registered_at_ms BIGINT NOT NULL) COLLATE utf8mb4_bin`
22
+ : `CREATE TABLE IF NOT EXISTS ${REPLICA_REGISTRATION_TABLE} (instance_id VARCHAR(64) COLLATE "C" NOT NULL PRIMARY KEY, release_line VARCHAR(64) COLLATE "C" NOT NULL, capabilities VARCHAR(1024) COLLATE "C" NOT NULL, host_name VARCHAR(255) COLLATE "C" NOT NULL, pid BIGINT NOT NULL, registered_at_ms BIGINT NOT NULL)`;
23
+ }
24
+ const ph = (dialect, i) => (dialect === "mysql" ? "?" : `$${i}`);
25
+ export async function registerReplica(db, dialect, me, nowMs) {
26
+ await db.query(`DELETE FROM ${REPLICA_REGISTRATION_TABLE} WHERE registered_at_ms < ${ph(dialect, 1)} AND instance_id <> ${ph(dialect, 2)}`, [nowMs - REPLICA_REGISTRATION_HORIZON_MS, me.instanceId]);
27
+ const insert = `INSERT INTO ${REPLICA_REGISTRATION_TABLE} (instance_id, release_line, capabilities, host_name, pid, registered_at_ms) VALUES (${[1, 2, 3, 4, 5, 6].map((i) => ph(dialect, i)).join(", ")})`;
28
+ await db.query(dialect === "mysql" ? `${insert} ON DUPLICATE KEY UPDATE instance_id = instance_id` : `${insert} ON CONFLICT (instance_id) DO NOTHING`, [
29
+ me.instanceId,
30
+ me.releaseLine,
31
+ capabilitiesCell(me.capabilities),
32
+ me.hostName,
33
+ me.pid,
34
+ nowMs,
35
+ ]);
36
+ }
37
+ export async function readReplicaRegistrations(db, dialect, instanceIds) {
38
+ const out = new Map();
39
+ if (instanceIds.length === 0)
40
+ return out;
41
+ const { rows } = await db.query(`SELECT instance_id, release_line, capabilities, host_name, pid FROM ${REPLICA_REGISTRATION_TABLE} WHERE instance_id IN (${instanceIds.map((_, i) => ph(dialect, i + 1)).join(", ")})`, [...instanceIds]);
42
+ for (const r of rows) {
43
+ const instanceId = String(r["instance_id"]);
44
+ out.set(instanceId, { instanceId, releaseLine: String(r["release_line"]), capabilities: capabilitiesOfCell(r["capabilities"]), hostName: String(r["host_name"]), pid: Number(r["pid"]) });
45
+ }
46
+ return out;
47
+ }
48
+ //# sourceMappingURL=replica-registration.js.map
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 托管留存执行面的**非破坏性 SQL 半场** —— SINGLE-FILE DUAL-DIALECT([ref] A12;先例 =
3
- * `retention-store-sql.ts` 本身)。[ref] 车2,设计稿 `docs/DESIGN-270-retention-lane.md` v1.3。
3
+ * `retention-store-sql.ts` 本身)。[ref] 车2,设计稿 `docs/[ref]-retention-lane.md` v1.3。
4
4
  *
5
5
  * ── 为什么与车1 的 `retention-store-sql.ts` 分家(而不是往那只店上再挂三组方法)────────────────────
6
6
  * 车1 那只店是 core `ManagedRetentionCapability` 的**实现体**:它的每一个公开方法都是一次**破坏性**
@@ -216,7 +216,7 @@ export declare class SqlRunStore {
216
216
  markResuming(taskId: string, instanceId: string): Promise<string | undefined>;
217
217
  /**
218
218
  * 认领当写栅栏用(进程内两孪生的契约 = `store-contracts.ts` 的 `InProcessClaimFence`;本孪生是它的事务形):一个事务 ——
219
- * ① 锁读这条 run 行且它在跑(`task_run … FOR UPDATE`);② 锁读会话的认领行且它是这条 run 的(`task_active … FOR UPDATE`);
219
+ * ① 锁读这条 run 行且它在跑(`task_run … FOR UPDATE`;副本与持有腿两列随行交给 `write`,见 `FencedRun`);② 锁读会话的认领行且它是这条 run 的(`task_active … FOR UPDATE`);
220
220
  * ③ 刷新这条 run 的活性戳;④ `write` 在**同一个事务连接**上跑(另收锁住那一行的 {@link FencedRun});提交即放锁。任一没读到 ⇒ 不持有,不刷新、`write` 不调。`write` 抛或提交失败 ⇒ 整只抛。
221
221
  * 🔴 **取锁序 = task_run → task_active**,与 `setTerminal` / `reapStale` / `reapSuspended` 同序(它们先改 run 行、再删认领行):两行用两条语句按序锁,
222
222
  * 不用连表锁读(那一形的先后由计划器定,与终局 / 收割相反时两只事务各攥一行成环)。`createRun` 先锁根会话行再插认领行(本序从不碰 `session_meta`)、
@@ -383,7 +383,7 @@ export class SqlRunStore {
383
383
  async whileHoldingClaim(sessionId, taskId, write) {
384
384
  let fenced = { held: false };
385
385
  await this.tx(async (conn) => {
386
- const run = await conn.query(this.q("SELECT task_id, instance_id FROM task_run WHERE task_id = ? AND status = 'running' FOR UPDATE", "SELECT task_id, instance_id FROM task_run WHERE task_id = $1 AND status = 'running' FOR UPDATE"), [taskId]);
386
+ const run = await conn.query(this.q("SELECT task_id, instance_id, leg_id FROM task_run WHERE task_id = ? AND status = 'running' FOR UPDATE", "SELECT task_id, instance_id, leg_id FROM task_run WHERE task_id = $1 AND status = 'running' FOR UPDATE"), [taskId]);
387
387
  const row = run.rows[0];
388
388
  if (row === undefined)
389
389
  return;
@@ -392,7 +392,7 @@ export class SqlRunStore {
392
392
  if (holder === undefined || String(holder.task_id) !== taskId)
393
393
  return;
394
394
  await conn.query(this.q("UPDATE task_run SET updated_at = ? WHERE task_id = ?", "UPDATE task_run SET updated_at = $1 WHERE task_id = $2"), [new Date(), taskId]);
395
- fenced = { held: true, value: await write(conn, { instanceId: row.instance_id ?? null }) };
395
+ fenced = { held: true, value: await write(conn, { instanceId: row.instance_id ?? null, legId: row.leg_id ?? null }) };
396
396
  });
397
397
  return fenced;
398
398
  }
@@ -1,4 +1,5 @@
1
1
  import { StoreBootRefusalError } from "./store-contracts.js";
2
+ import { type OlderReplicaEvidence } from "./stop-then-start-sentence.js";
2
3
  /** 本条的拒启码(机读)。 */
3
4
  export declare const SCENARIO_PREFIX_SWITCH_REFUSED_CODE = "config.scenario_prefix_switch_refused";
4
5
  /** 本条在标记表(`one-time-migration-marker.ts`)里的那一行的键。 */
@@ -18,26 +19,19 @@ export interface ScenarioPrefixHit {
18
19
  export interface ScenarioPrefixReceipt {
19
20
  scenarioPrefixSourcesScanned: number;
20
21
  }
21
- /** 拒因闭集:存量命中 / 零命中而更早版本的副本可能仍在服务(SQL 的停-迁-起)。 */
22
- export type ScenarioPrefixRefusalReason = "stored_prefix" | "live_runs";
23
- /** 判活读数(`live_runs` 那一拒带):判活窗内心跳过的 run 数与可以再起的最早时刻。 */
24
- export interface ScenarioPrefixLiveRuns {
25
- readonly liveRuns: number;
26
- readonly windowSec: number;
27
- readonly restartAfter: string;
28
- readonly after: string;
29
- }
22
+ /** 拒因闭集:存量命中 / 零命中而有停-迁-起证据(更早版本的副本可能仍在服务;SQL 的停-迁-起)。 */
23
+ export type ScenarioPrefixRefusalReason = "stored_prefix" | "older_replica";
30
24
  /**
31
25
  * M42 的起服拒。具名类型 + 机读码 + 拒因;继承 {@link StoreBootRefusalError}(SQL 开库的等待窗不重试它;隐式 local 的降级臂不吞)。一个字节没改(扫描只读,标记没写)。
32
26
  * · `stored_prefix` —— 存量里有以保留前缀开头的身份(`hits` 逐来源点名);
33
- * · `live_runs` —— 零命中,但判活窗内仍有 run 在心跳:更早版本的副本可能还在服务,扫描之后它还会收下这类身份 ⇒ 全停之后再起(`live` 带时刻)。
27
+ * · `older_replica` —— 零命中,但有停-迁-起证据:更早版本的副本可能还在服务,扫描之后它还会收下这类身份 ⇒ 全停之后再起(`live` = 证据,带时刻)。
34
28
  */
35
29
  export declare class ScenarioPrefixSwitchRefusedError extends StoreBootRefusalError {
36
30
  readonly reason: ScenarioPrefixRefusalReason;
37
31
  readonly hits: readonly ScenarioPrefixHit[];
38
- readonly live?: ScenarioPrefixLiveRuns | undefined;
32
+ readonly live?: OlderReplicaEvidence | undefined;
39
33
  readonly code = "config.scenario_prefix_switch_refused";
40
- constructor(reason: ScenarioPrefixRefusalReason, hits: readonly ScenarioPrefixHit[], live?: ScenarioPrefixLiveRuns | undefined,
34
+ constructor(reason: ScenarioPrefixRefusalReason, hits: readonly ScenarioPrefixHit[], live?: OlderReplicaEvidence | undefined,
41
35
  /** 库此刻还差的列(SQL 腿经 `describeUpgradeDriftTail` 读到的;列齐 / local 腿 ⇒ 空串):一次起服读全。 */
42
36
  tail?: string);
43
37
  private static sentence;
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { SCENARIO_SUBJECT_PREFIX } from "../owner-brands.js";
3
3
  import { atomicWriteFileSync } from "./owner-only-file-mode.js";
4
4
  import { StoreBootRefusalError } from "./store-contracts.js";
5
- import { stopThenStartAction } from "./stop-then-start-sentence.js";
5
+ import { olderReplicaEvidenceSentence, stopThenStartAction } from "./stop-then-start-sentence.js";
6
6
  export const SCENARIO_PREFIX_SWITCH_REFUSED_CODE = "config.scenario_prefix_switch_refused";
7
7
  export const SCENARIO_PREFIX_MARKER_ID = "M42";
8
8
  export const LOCAL_MARKER_FILE = "one-time-migration-M42.json";
@@ -28,10 +28,12 @@ export class ScenarioPrefixSwitchRefusedError extends StoreBootRefusalError {
28
28
  `). This version reads that prefix as a scenario of a person, so these rows would be attributed to another person; it refuses to start rather than misread them. ` +
29
29
  `Nothing was changed. Rename or delete those identities (the identity boundary now rejects principals with this prefix), then restart — ` +
30
30
  `the check runs until it finds none, then records a marker in the store and does not run again.`);
31
- case "live_runs":
32
- return (`${SCENARIO_PREFIX_SWITCH_REFUSED_CODE}: no stored identity starts with the reserved prefix "${SCENARIO_SUBJECT_PREFIX}", but ${live?.liveRuns ?? 0} run(s) heartbeated on this database ` +
33
- `within the last ${live?.windowSec ?? 0}s — a replica of an earlier version may still be serving, and it accepts principals with that prefix, so a clean check now would not stay clean. ` +
34
- `Nothing was changed and no marker was written. ${stopThenStartAction(live?.after)}`);
31
+ case "older_replica":
32
+ if (live === undefined)
33
+ throw new Error("scenario-prefix-switch: an older_replica refusal carries its evidence");
34
+ return (`${SCENARIO_PREFIX_SWITCH_REFUSED_CODE}: no stored identity starts with the reserved prefix "${SCENARIO_SUBJECT_PREFIX}", but ${olderReplicaEvidenceSentence(live)} ` +
35
+ `— a replica of an earlier version may still be serving, and it accepts principals with that prefix, so a clean check now would not stay clean. ` +
36
+ `Nothing was changed and no marker was written. ${stopThenStartAction(live.after)}`);
35
37
  default: {
36
38
  const unhandled = reason;
37
39
  throw new Error(`scenario-prefix-switch: unknown refusal reason ${String(unhandled)}`);
@@ -185,11 +185,13 @@ export declare function describeMismatchedTables(dialect: SqlDialect, mismatches
185
185
  /**
186
186
  * **升级清单**(多行;`[]` = 没有语句要部署者执行,清单不出):一次起服的拒句里「这只库此刻对本构建还差什么、先后顺序」一段。
187
187
  * 缺列拒、判活拒、一次性迁移的拒本来各报各的一段 —— 运维补一句再起、才撞上下一族(列册对账在迁移段之后时,第四次起服才拒出其余全部 ALTER)。
188
- * 清单把本构建对存量库的全部动作写成一张:① 逐表的 ALTER(其下 {@link describeMismatchedTables} 的逐表段,全库一次列全)② 本构建带的停-迁-起迁移(登记表 `stopThenStart` 一格,
189
- * 行在 ⇒ 码在;一次性迁移文件静态依赖本文件,所以惰性载入)③ 起服。第 ② 步对「已经走过这些迁移」的库是空操作(迁移在自己的守卫处判),清单不替守卫判「这一只库欠不欠」。
188
+ * 清单把本构建对这一只库的全部动作写成一张:① 逐表的 ALTER(其下 {@link describeMismatchedTables} 的逐表段,全库一次列全)② 这只库**还欠**的停-迁-起迁移
189
+ * ③ 起服。第 ② 步逐行问登记行的 `stopThenStart.owedOn`(一次性迁移文件静态依赖本文件,所以惰性载入)—— 那条迁移的守卫自己问的同一只判据,清单不另判:
190
+ * 已经走过它们的库(标记落了 / 部署格已搬 / 零行旧词)一条不欠 ⇒ 第 ② 步与它的「不可滚动」动作段都不出,起服是第 ② 步。某一条读不出欠不欠(探针读抛)⇒ 照列并如实说
191
+ * 没读到(多停一次,不漏停)。`read` = 列册对账读列册的同一只无参读口。
190
192
  */
191
- export declare function describeUpgradeChecklist(mismatches: readonly TableMismatch[]): Promise<string[]>;
192
- /** 拒启文案(表头 + {@link describeUpgradeChecklist}〔有语句要执行时〕+ 逐表段与闭集三条出路 {@link describeMismatchedTables})。`checklist` 由调用方 `await describeUpgradeChecklist(…)` 交来(本函数保持同步纯函数)。 */
193
+ export declare function describeUpgradeChecklist(read: SchemaProbeRead, dialect: SqlDialect, mismatches: readonly TableMismatch[]): Promise<string[]>;
194
+ /** 拒启文案(表头 + {@link describeUpgradeChecklist}〔有语句要执行时〕+ 逐表段与闭集三条出路 {@link describeMismatchedTables})。`checklist` 由调用方 `await describeUpgradeChecklist(…)` 交来(本函数保持同步纯函数,不读库)。 */
193
195
  export declare function describeSchemaMismatch(dialect: SqlDialect, mismatches: readonly TableMismatch[], checklist?: readonly string[]): string;
194
196
  /**
195
197
  * 迁移段**之前 / 之中**读列册的拒句(单表探针的拒因、判活守卫的句尾)要的那一份:库经本构建**全部待跑迁移**之后({@link projectPendingShape},登记表各行的 `shapeChanges`,
@@ -201,7 +203,7 @@ export declare function describeSchemaMismatch(dialect: SqlDialect, mismatches:
201
203
  */
202
204
  export declare function statementBearing(expected: Map<string, ParsedTable>, live: LiveColumns, dialect: SqlDialect): Promise<TableMismatch[]>;
203
205
  /** 清单 + 逐表段 + 闭集三条出路(多行):单表探针的拒因与判活守卫的句尾共用的正文(列册对账的拒句是 {@link describeSchemaMismatch},同一份清单与逐表段)。 */
204
- export declare function describeUpgradeBody(dialect: SqlDialect, mismatches: readonly TableMismatch[]): Promise<string[]>;
206
+ export declare function describeUpgradeBody(read: SchemaProbeRead, dialect: SqlDialect, mismatches: readonly TableMismatch[]): Promise<string[]>;
205
207
  /** 句尾读列册的期限(ms):这一段是拒句上的附加,不许把已经成立的拒拖住 —— 读不回来就如实说没读到,拒照出。 */
206
208
  export declare const DRIFT_TAIL_DEADLINE_MS = 10000;
207
209
  /**
@@ -452,21 +452,33 @@ export function describeMismatchedTables(dialect, mismatches) {
452
452
  function needsStatements(mismatches) {
453
453
  return mismatches.some((m) => m.missing.length > 0 || m.tightened.length > 0);
454
454
  }
455
- export async function describeUpgradeChecklist(mismatches) {
455
+ export async function describeUpgradeChecklist(read, dialect, mismatches) {
456
456
  if (!needsStatements(mismatches))
457
457
  return [];
458
458
  const { stopThenStartMigrations } = await import("./one-time-migrations.js");
459
- const stops = stopThenStartMigrations();
459
+ const owed = [];
460
+ for (const m of stopThenStartMigrations()) {
461
+ let owes;
462
+ try {
463
+ owes = await m.owedOn(read, dialect);
464
+ }
465
+ catch (err) {
466
+ owed.push(` - ${m.id}: ${m.reason} (whether this database still owes it could not be read — ${err instanceof Error ? err.message : String(err)} — so it is listed)`);
467
+ continue;
468
+ }
469
+ if (owes)
470
+ owed.push(` - ${m.id}: ${m.reason}`);
471
+ }
460
472
  const steps = [["Run every statement printed below, each recipe in the order it is printed (a printed comment line marks a value only you can choose) — all tables at once, before anything else (this build never alters a database for you)."]];
461
- if (stops.length > 0) {
473
+ if (owed.length > 0) {
462
474
  steps.push([
463
- "Stop-then-start one-time migrations: this build runs them itself on its first boot (no statement from you), and each refuses to start — writing nothing — while any run on this database has",
464
- "heartbeated within REAP_RUN_STALE_SEC. A database that has already been through one of them is not asked again.",
465
- ...stops.map((m) => ` - ${m.id}: ${m.reason}`),
475
+ "Stop-then-start one-time migrations this database still owes: this build runs them itself on its first boot (no statement from you), and each refuses to start — writing nothing — while a run on this database has",
476
+ "heartbeated within REAP_RUN_STALE_SEC held by a replica not registered as knowing that migration (a replica of a release that predates replica registration never is).",
477
+ ...owed,
466
478
  stopThenStartAction(),
467
479
  ]);
468
480
  }
469
- steps.push(["Start this version; the one-time migrations above (if any are still owed) run on that boot."]);
481
+ steps.push([owed.length > 0 ? "Start this version; the stop-then-start migrations above run on that boot." : "Start this version."]);
470
482
  return [
471
483
  "Upgrade checklist — everything this build needs from you on this database, in this order (one start lists all of it; do not fix one table and retry):",
472
484
  ...steps.flatMap((lines, i) => lines.map((l, k) => (k === 0 ? ` ${i + 1}. ${l}` : ` ${l}`))),
@@ -489,22 +501,26 @@ export async function statementBearing(expected, live, dialect) {
489
501
  .map((m) => ({ ...m, ghost: [] }))
490
502
  .filter((m) => m.missing.length > 0 || m.tightened.length > 0);
491
503
  }
492
- export async function describeUpgradeBody(dialect, mismatches) {
493
- const checklist = await describeUpgradeChecklist(mismatches);
504
+ export async function describeUpgradeBody(read, dialect, mismatches) {
505
+ const checklist = await describeUpgradeChecklist(read, dialect, mismatches);
494
506
  return [...(checklist.length > 0 ? [...checklist, ""] : []), ...describeMismatchedTables(dialect, mismatches)];
495
507
  }
496
508
  export const DRIFT_TAIL_DEADLINE_MS = 10_000;
497
509
  export async function describeUpgradeDriftTail(read, dialect, deadlineMs = DRIFT_TAIL_DEADLINE_MS) {
498
- let mismatches;
499
510
  let timer;
500
511
  try {
501
- const work = (async () => statementBearing(await expectedTableShapes(dialect), await readLiveColumns(read, dialect), dialect))();
512
+ const work = (async () => {
513
+ const mismatches = await statementBearing(await expectedTableShapes(dialect), await readLiveColumns(read, dialect), dialect);
514
+ if (mismatches.length === 0)
515
+ return "";
516
+ return `\n\nThis database also does not match this build's schema — the statements below are the ones the column check prints at boot:\n${(await describeUpgradeBody(read, dialect, mismatches)).join("\n")}`;
517
+ })();
502
518
  work.catch(() => undefined);
503
519
  const expired = new Promise((_, reject) => {
504
520
  timer = setTimeout(() => reject(new Error(`no answer within ${deadlineMs} ms`)), deadlineMs);
505
521
  timer.unref?.();
506
522
  });
507
- mismatches = await Promise.race([work, expired]);
523
+ return await Promise.race([work, expired]);
508
524
  }
509
525
  catch (err) {
510
526
  return `\n\nThe column check could not be read here (${err instanceof Error ? err.message : String(err)}), so any ALTER this database still owes is not listed in this refusal — the column check at boot lists them.`;
@@ -512,9 +528,6 @@ export async function describeUpgradeDriftTail(read, dialect, deadlineMs = DRIFT
512
528
  finally {
513
529
  clearTimeout(timer);
514
530
  }
515
- if (mismatches.length === 0)
516
- return "";
517
- return `\n\nThis database also does not match this build's schema — the statements below are the ones the column check prints at boot:\n${(await describeUpgradeBody(dialect, mismatches)).join("\n")}`;
518
531
  }
519
532
  export async function assertSchemaMatchesCode(read, dialect, pending) {
520
533
  const expected = await expectedTableShapes(dialect);
@@ -533,7 +546,7 @@ export async function assertSchemaMatchesCode(read, dialect, pending) {
533
546
  }
534
547
  const mismatches = diffSchema(expected, projectPendingShape(expected, live, pending, dialect).projected);
535
548
  if (mismatches.length > 0)
536
- throw new SchemaMismatchRefusedError(describeSchemaMismatch(dialect, mismatches, await describeUpgradeChecklist(mismatches)));
549
+ throw new SchemaMismatchRefusedError(describeSchemaMismatch(dialect, mismatches, await describeUpgradeChecklist(read, dialect, mismatches)));
537
550
  }
538
551
  const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
539
552
  const RETIRED_STEER_SEAT = { table: "checkpoint", column: "pending_steer" };
@@ -28,7 +28,7 @@ async function describeProbedTableDrift(query, dialect, tables) {
28
28
  const sc = await import("./schema-consistency.js");
29
29
  const diff = await sc.statementBearing(await sc.expectedTableShapes(d), await readLiveColumns(read, d), d);
30
30
  if (diff.some((m) => tables.includes(m.table) && m.missing.length > 0))
31
- return (await sc.describeUpgradeBody(d, diff)).join("\n");
31
+ return (await sc.describeUpgradeBody(read, d, diff)).join("\n");
32
32
  why = "this build's table definitions list no column that the database lacks on that table, so the probe and the definitions disagree";
33
33
  }
34
34
  catch (err) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * **起服准入**(SQL 两方言;常设)—— 「这只库要求每一只服务者有哪些能力」一张表,与本构建的能力名单比:库上记着本构建没有的能力 ⇒ 拒启,本构建不迁移、不服务。
3
+ *
4
+ * ## 律
5
+ * · 一行一能力:`migration_id` = 能力名(= 一次性迁移名,`replica-registration.ts` 的 {@link REPLICA_CAPABILITIES} 同一词表),`state` 两词闭集
6
+ * {@link SERVING_REQUIREMENT_STATES}:`pending`(要求方已认领、还没做任何不兼容写)/ `in_force`(不兼容写已开始或已完成)。两态都要求;读出词表外的词 ⇒ 当作 `in_force`,拒句点名那一行。
7
+ * · 判据 = `knows(本构建, M)`(`knowsCapability`,与停-迁-起证据读法同一只谓词);发布线不进判据(同版本串的构建可以能力不同)。
8
+ * · 两次读,同一判据:SQL 一次性迁移段头、取迁移锁之前读一次(锁外预读);取到迁移锁之后、第一步迁移之前在锁连接上再读一次(锁内复核)——
9
+ * 在本进程等锁时握着锁的那一只若写下了要求,锁内复核读得到。拒 ⇒ 本构建不迁移;库上只留本进程的登记行(段头第一步)。
10
+ * · 表不在(跳过了 `ensureSchema()` 的嵌入方)⇒ 拒:证不出「没有要求」不是「没有要求」。
11
+ * · 本构建不写本表(没有写者;写者随之后的停-迁-起迁移来)—— 表空 ⇒ 准入恒过。装早一版:从本版起的每一只构建都读它,之后写下的要求挡得住它们重启开服。
12
+ *
13
+ * ## 射程(如实)
14
+ * 挡的是**起服者**:已在服务的副本不再读本表;准入与开服之间的停顿也不在它的射程里。早于本表那一版的构建不读本表。
15
+ */
16
+ import { StoreBootRefusalError } from "./store-contracts.js";
17
+ import { type ReplicaRegistration } from "./replica-registration.js";
18
+ import { type UnmetServingRequirement } from "./stop-then-start-sentence.js";
19
+ import type { SqlDialect, SqlExec } from "./sql-driver.js";
20
+ /** 表名。 */
21
+ export declare const SERVING_REQUIREMENT_TABLE = "serving_requirement";
22
+ /** 要求行的状态词(闭集)。 */
23
+ export declare const SERVING_REQUIREMENT_STATES: readonly ["pending", "in_force"];
24
+ export type ServingRequirementState = (typeof SERVING_REQUIREMENT_STATES)[number];
25
+ /** 拒启码(确定的拒启表态;继承 {@link StoreBootRefusalError}:开库等待窗不重试、降级臂不吞)。 */
26
+ export declare const SERVING_REQUIREMENT_UNMET_CODE = "config.serving_requirement_unmet";
27
+ /** 拒因闭集:库要求的能力本构建没有 / 准入读的表不在库里(证不出)。 */
28
+ export type ServingRequirementRefusalReason = "capability_missing" | "table_absent";
29
+ /** 建表句(两方言;幂等)。中央建表语句读它(`tidb-pool.ts` / `pg-pool.ts`)。 */
30
+ export declare function servingRequirementDdl(dialect: SqlDialect): string;
31
+ /** 起服准入拒。 */
32
+ export declare class ServingRequirementUnmetError extends StoreBootRefusalError {
33
+ readonly reason: ServingRequirementRefusalReason;
34
+ /** `capability_missing`:本构建没有的那几行(按能力名排序);`table_absent`:空。 */
35
+ readonly unmet: readonly UnmetServingRequirement[];
36
+ /** `table_absent`:不在库里的那几张表;`capability_missing`:空。 */
37
+ readonly absentTables: readonly string[];
38
+ readonly code = "config.serving_requirement_unmet";
39
+ constructor(reason: ServingRequirementRefusalReason,
40
+ /** `capability_missing`:本构建没有的那几行(按能力名排序);`table_absent`:空。 */
41
+ unmet: readonly UnmetServingRequirement[],
42
+ /** `table_absent`:不在库里的那几张表;`capability_missing`:空。 */
43
+ absentTables: readonly string[], releaseLine: string);
44
+ }
45
+ /** 准入读的两张表(本表与段头写的登记表)都在库里;不在 ⇒ 拒(`table_absent`),点名公开序列。 */
46
+ export declare function assertReplicaTablesPresent(db: SqlExec, dialect: SqlDialect, me: ReplicaRegistration): Promise<void>;
47
+ /** 准入:库上每一行要求的能力本构建都有 ⇒ 过;否则拒(`capability_missing`,点名每一行)。纯读;两方言同一句(无占位符)。 */
48
+ export declare function assertServingRequirementsMet(db: SqlExec, me: ReplicaRegistration): Promise<void>;
49
+ //# sourceMappingURL=serving-requirement.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { StoreBootRefusalError } from "./store-contracts.js";
2
+ import { REPLICA_REGISTRATION_TABLE, knowsCapability } from "./replica-registration.js";
3
+ import { servingRequirementUnmetSentence } from "./stop-then-start-sentence.js";
4
+ export const SERVING_REQUIREMENT_TABLE = "serving_requirement";
5
+ export const SERVING_REQUIREMENT_STATES = ["pending", "in_force"];
6
+ export const SERVING_REQUIREMENT_UNMET_CODE = "config.serving_requirement_unmet";
7
+ export function servingRequirementDdl(dialect) {
8
+ return dialect === "mysql"
9
+ ? `CREATE TABLE IF NOT EXISTS ${SERVING_REQUIREMENT_TABLE} (migration_id VARCHAR(16) NOT NULL PRIMARY KEY, state VARCHAR(16) NOT NULL, required_at_ms BIGINT NOT NULL) COLLATE utf8mb4_bin`
10
+ : `CREATE TABLE IF NOT EXISTS ${SERVING_REQUIREMENT_TABLE} (migration_id VARCHAR(16) COLLATE "C" NOT NULL PRIMARY KEY, state VARCHAR(16) COLLATE "C" NOT NULL, required_at_ms BIGINT NOT NULL)`;
11
+ }
12
+ export class ServingRequirementUnmetError extends StoreBootRefusalError {
13
+ reason;
14
+ unmet;
15
+ absentTables;
16
+ code = SERVING_REQUIREMENT_UNMET_CODE;
17
+ constructor(reason, unmet, absentTables, releaseLine) {
18
+ super(servingRequirementUnmetSentence(SERVING_REQUIREMENT_UNMET_CODE, reason, unmet, absentTables, releaseLine));
19
+ this.reason = reason;
20
+ this.unmet = unmet;
21
+ this.absentTables = absentTables;
22
+ this.name = "ServingRequirementUnmetError";
23
+ }
24
+ }
25
+ export async function assertReplicaTablesPresent(db, dialect, me) {
26
+ const scope = dialect === "mysql" ? "DATABASE()" : "current_schema()";
27
+ const absent = [];
28
+ for (const table of [REPLICA_REGISTRATION_TABLE, SERVING_REQUIREMENT_TABLE]) {
29
+ const { rows } = await db.query(`SELECT 1 AS present FROM information_schema.tables WHERE table_schema = ${scope} AND table_name = '${table}'`);
30
+ if (rows.length === 0)
31
+ absent.push(table);
32
+ }
33
+ if (absent.length > 0)
34
+ throw new ServingRequirementUnmetError("table_absent", [], absent, me.releaseLine);
35
+ }
36
+ function stateOf(cell) {
37
+ const word = String(cell);
38
+ return SERVING_REQUIREMENT_STATES.includes(word) ? { state: word, word } : { state: "in_force", word };
39
+ }
40
+ export async function assertServingRequirementsMet(db, me) {
41
+ const { rows } = await db.query(`SELECT migration_id, state FROM ${SERVING_REQUIREMENT_TABLE} ORDER BY migration_id`);
42
+ const unmet = [];
43
+ for (const r of rows) {
44
+ const migration = String(r["migration_id"]);
45
+ if (knowsCapability(me.capabilities, migration))
46
+ continue;
47
+ const { state, word } = stateOf(r["state"]);
48
+ unmet.push({ migration, state, word });
49
+ }
50
+ if (unmet.length > 0)
51
+ throw new ServingRequirementUnmetError("capability_missing", unmet, [], me.releaseLine);
52
+ }
53
+ //# sourceMappingURL=serving-requirement.js.map