@sema-agent/server 7.124.1 → 7.125.1

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 (152) hide show
  1. package/MIGRATION.md +138 -2
  2. package/USAGE.md +46 -8
  3. package/dist/audit.d.ts +27 -5
  4. package/dist/audit.js +42 -8
  5. package/dist/boot/execution-env.d.ts +33 -1
  6. package/dist/boot/execution-env.js +31 -2
  7. package/dist/boot/resolve-spec.js +4 -3
  8. package/dist/boot/session-faces.d.ts +14 -1
  9. package/dist/boot/session-faces.js +6 -5
  10. package/dist/boot/stage-02-web-admin-pull.js +5 -1
  11. package/dist/boot/stage-05-execution-env.js +6 -2
  12. package/dist/boot/stage-09-leader.d.ts +1 -1
  13. package/dist/boot/stage-10-http-server.d.ts +1 -1
  14. package/dist/boot/web-admin.js +130 -140
  15. package/dist/capabilities/center-wiring.d.ts +2 -1
  16. package/dist/capabilities/composite-members.d.ts +1 -0
  17. package/dist/capabilities/composite-members.js +1 -0
  18. package/dist/capability-facts.d.ts +28 -3
  19. package/dist/capability-facts.js +20 -0
  20. package/dist/config-catalog.js +4 -1
  21. package/dist/config-d-migration.js +2 -1
  22. package/dist/config-lkg.d.ts +4 -1
  23. package/dist/config-lkg.js +16 -12
  24. package/dist/config-provider.d.ts +4 -1
  25. package/dist/config-provider.js +1 -0
  26. package/dist/config-types.d.ts +10 -3
  27. package/dist/config.js +44 -14
  28. package/dist/env-facts.d.ts +2 -2
  29. package/dist/env-facts.js +3 -2
  30. package/dist/fleet/fleet-bus.d.ts +24 -12
  31. package/dist/fleet/fleet-bus.js +4 -1
  32. package/dist/fleet/workspace-snapshot-residual.d.ts +10 -0
  33. package/dist/fleet/workspace-snapshot-residual.js +35 -0
  34. package/dist/hooks/hook-runner.d.ts +15 -27
  35. package/dist/hooks/hook-runner.js +19 -59
  36. package/dist/http/active-run-conflict.d.ts +3 -0
  37. package/dist/http/active-run-conflict.js +3 -0
  38. package/dist/http/admission.js +7 -7
  39. package/dist/http/approval-sweeps.js +2 -4
  40. package/dist/http/device-placement.js +1 -0
  41. package/dist/http/dispatch.js +1 -1
  42. package/dist/http/durable-run-leg.d.ts +20 -6
  43. package/dist/http/durable-run-leg.js +4 -4
  44. package/dist/http/idempotency.d.ts +83 -38
  45. package/dist/http/idempotency.js +38 -13
  46. package/dist/http/request-guards.d.ts +18 -0
  47. package/dist/http/request-guards.js +12 -3
  48. package/dist/http/resume-legs.js +7 -1
  49. package/dist/http/route-ctx.d.ts +3 -3
  50. package/dist/http/route-ctx.js +1 -1
  51. package/dist/http/routes/a2a-serve.js +27 -11
  52. package/dist/http/routes/capabilities.js +1 -0
  53. package/dist/http/routes/fleet.js +2 -0
  54. package/dist/http/routes/images.js +2 -2
  55. package/dist/http/routes/leader.d.ts +4 -0
  56. package/dist/http/routes/leader.js +13 -3
  57. package/dist/http/routes/observability.js +2 -0
  58. package/dist/http/routes/runs.d.ts +6 -2
  59. package/dist/http/routes/runs.js +71 -101
  60. package/dist/http/routes/session-sync.d.ts +3 -0
  61. package/dist/http/routes/session-sync.js +30 -1
  62. package/dist/http/routes/sessions-list.d.ts +11 -0
  63. package/dist/http/routes/sessions-list.js +6 -6
  64. package/dist/http/routes/sessions.d.ts +6 -1
  65. package/dist/http/routes/sessions.js +41 -10
  66. package/dist/http/routes/tasks.js +63 -21
  67. package/dist/http/run-read-face.d.ts +26 -0
  68. package/dist/http/run-read-face.js +67 -0
  69. package/dist/http/server.d.ts +4 -4
  70. package/dist/http/server.js +1 -2
  71. package/dist/http/submit-replay.d.ts +40 -0
  72. package/dist/http/submit-replay.js +22 -0
  73. package/dist/leader/endpoint.d.ts +10 -5
  74. package/dist/leader/endpoint.js +27 -5
  75. package/dist/observability/cost-quota.d.ts +51 -7
  76. package/dist/observability/cost-quota.js +35 -17
  77. package/dist/observability/fail-open.d.ts +1 -1
  78. package/dist/observability/fail-open.js +1 -1
  79. package/dist/observability/run-terminal.d.ts +5 -9
  80. package/dist/observability/run-terminal.js +2 -7
  81. package/dist/plugins/approval-ask-store-sql.js +1 -1
  82. package/dist/plugins/caching-session-store.d.ts +7 -2
  83. package/dist/plugins/caching-session-store.js +6 -0
  84. package/dist/plugins/device-store-sql.js +1 -1
  85. package/dist/plugins/ensure-index.js +22 -0
  86. package/dist/plugins/file-run-store.d.ts +24 -14
  87. package/dist/plugins/file-run-store.js +52 -12
  88. package/dist/plugins/leader-run-store-sql.d.ts +1 -11
  89. package/dist/plugins/leader-run-store-sql.js +1 -5
  90. package/dist/plugins/local-session-store.d.ts +35 -6
  91. package/dist/plugins/local-session-store.js +87 -18
  92. package/dist/plugins/memory-run-store.d.ts +13 -12
  93. package/dist/plugins/memory-run-store.js +25 -0
  94. package/dist/plugins/one-time-migrations.js +1 -1
  95. package/dist/plugins/pg-cost-quota.d.ts +1 -1
  96. package/dist/plugins/pg-cost-quota.js +2 -5
  97. package/dist/plugins/pg-pool.js +3 -0
  98. package/dist/plugins/pg-session-storage.d.ts +11 -1
  99. package/dist/plugins/pg-session-storage.js +44 -8
  100. package/dist/plugins/remote-env-k8s.d.ts +127 -12
  101. package/dist/plugins/remote-env-k8s.js +270 -27
  102. package/dist/plugins/run-store-sql.d.ts +26 -12
  103. package/dist/plugins/run-store-sql.js +50 -25
  104. package/dist/plugins/schema-consistency.d.ts +1 -13
  105. package/dist/plugins/schema-consistency.js +1 -41
  106. package/dist/plugins/schema-indexes.js +1 -0
  107. package/dist/plugins/schema-live-columns.d.ts +20 -0
  108. package/dist/plugins/schema-live-columns.js +42 -0
  109. package/dist/plugins/store-contracts.d.ts +40 -2
  110. package/dist/plugins/tidb-cost-quota.d.ts +1 -1
  111. package/dist/plugins/tidb-cost-quota.js +2 -5
  112. package/dist/plugins/tidb-pool.js +14 -3
  113. package/dist/plugins/tidb-session-store.d.ts +22 -2
  114. package/dist/plugins/tidb-session-store.js +33 -6
  115. package/dist/plugins/write-behind-counter.d.ts +12 -5
  116. package/dist/plugins/write-behind-counter.js +14 -7
  117. package/dist/prompts-domain-validate.js +3 -2
  118. package/dist/rules-consent.d.ts +2 -2
  119. package/dist/rules-consent.js +2 -2
  120. package/dist/run-local.js +31 -23
  121. package/dist/runs.d.ts +9 -0
  122. package/dist/runs.js +8 -0
  123. package/dist/runtime-governance.d.ts +3 -3
  124. package/dist/runtime-governance.js +2 -4
  125. package/dist/security.d.ts +19 -2
  126. package/dist/session-row.d.ts +53 -0
  127. package/dist/session-row.js +47 -0
  128. package/dist/session-sync.d.ts +6 -0
  129. package/dist/session-sync.js +5 -1
  130. package/dist/session-titler.d.ts +9 -5
  131. package/dist/session-titler.js +3 -1
  132. package/dist/spec-fields.js +2 -2
  133. package/dist/task-cwd.d.ts +0 -20
  134. package/dist/task-cwd.js +2 -10
  135. package/dist/tool-approval.d.ts +5 -7
  136. package/dist/trace/redact.d.ts +23 -50
  137. package/dist/trace/redact.js +42 -9
  138. package/dist/trace/self-minted-wire.d.ts +25 -0
  139. package/dist/trace/self-minted-wire.js +9 -0
  140. package/dist/web-admin-client/apply-effective.d.ts +10 -12
  141. package/dist/web-admin-client/apply-effective.js +2 -3
  142. package/dist/web-admin-client/candidate-gate.d.ts +69 -0
  143. package/dist/web-admin-client/candidate-gate.js +27 -0
  144. package/dist/web-admin-client/http-client.d.ts +2 -1
  145. package/dist/web-admin-client/http-client.js +2 -1
  146. package/dist/web-admin-client/pull-state.d.ts +6 -4
  147. package/dist/web-admin-client/read-warnings.d.ts +29 -5
  148. package/dist/web-admin-client/read-warnings.js +24 -4
  149. package/dist/web-admin-client/stage-limits.js +4 -1
  150. package/dist/workspace-space.d.ts +41 -0
  151. package/dist/workspace-space.js +5 -0
  152. package/package.json +3 -3
@@ -1,3 +1,24 @@
1
+ export const COST_QUOTA_WINDOW_SEC_RANGE = { min: 1, max: 1e10 };
2
+ export function costQuotaWindowSecInRange(sec) {
3
+ return Number.isFinite(sec) && sec >= COST_QUOTA_WINDOW_SEC_RANGE.min && sec <= COST_QUOTA_WINDOW_SEC_RANGE.max;
4
+ }
5
+ export function alignedWindow(now, windowMs) {
6
+ const bucket = Math.floor(now / windowMs);
7
+ return { now, bucket, windowEndsAtMs: (bucket + 1) * windowMs };
8
+ }
9
+ export function retryAfterSecUntil(w) {
10
+ return Math.max(0, Math.ceil((w.windowEndsAtMs - w.now) / 1000));
11
+ }
12
+ export function decideQuota(limitMicroUsd, w, readUsed) {
13
+ if (limitMicroUsd <= 0)
14
+ return { allowed: true, usedMicroUsd: 0, limitMicroUsd: 0, retryAfterSec: 0, windowEndsAtMs: w.windowEndsAtMs };
15
+ const usedMicroUsd = readUsed(w.bucket);
16
+ const allowed = usedMicroUsd < limitMicroUsd;
17
+ return { allowed, usedMicroUsd, limitMicroUsd, retryAfterSec: allowed ? 0 : retryAfterSecUntil(w), windowEndsAtMs: w.windowEndsAtMs };
18
+ }
19
+ export function quotaWindowResetsAt(d) {
20
+ return new Date(d.windowEndsAtMs).toISOString();
21
+ }
1
22
  export class CostQuota {
2
23
  now;
3
24
  windows = new Map();
@@ -16,33 +37,26 @@ export class CostQuota {
16
37
  }
17
38
  }
18
39
  check(key) {
19
- if (this.limitMicroUsd <= 0)
20
- return { allowed: true, usedMicroUsd: 0, limitMicroUsd: 0, retryAfterSec: 0 };
21
- const t = this.now();
22
- const w = this.windows.get(key);
23
- const live = w && t - w.start < this.windowMs;
24
- const used = live ? w.micro : 0;
25
- const allowed = used < this.limitMicroUsd;
26
- const retryAfterSec = !allowed && live ? Math.ceil((w.start + this.windowMs - t) / 1000) : 0;
27
- return { allowed, usedMicroUsd: used, limitMicroUsd: this.limitMicroUsd, retryAfterSec };
40
+ return decideQuota(this.limitMicroUsd, alignedWindow(this.now(), this.windowMs), (bucket) => this.windows.get(entryKey(key, bucket))?.micro ?? 0);
28
41
  }
29
42
  add(key, micro) {
30
43
  if (this.limitMicroUsd <= 0)
31
44
  return;
32
45
  if (micro <= 0)
33
46
  return;
34
- const t = this.now();
35
- const w = this.windows.get(key);
36
- if (!w || t - w.start >= this.windowMs)
37
- this.windows.set(key, { start: t, micro });
47
+ const { bucket } = alignedWindow(this.now(), this.windowMs);
48
+ const k = entryKey(key, bucket);
49
+ const e = this.windows.get(k);
50
+ if (e)
51
+ e.micro += micro;
38
52
  else
39
- w.micro += micro;
53
+ this.windows.set(k, { bucket, micro });
40
54
  }
41
55
  reap() {
42
- const t = this.now();
56
+ const { bucket } = alignedWindow(this.now(), this.windowMs);
43
57
  let dropped = 0;
44
- for (const [k, w] of this.windows) {
45
- if (t - w.start >= this.windowMs) {
58
+ for (const [k, e] of this.windows) {
59
+ if (e.bucket < bucket) {
46
60
  this.windows.delete(k);
47
61
  dropped++;
48
62
  }
@@ -50,4 +64,8 @@ export class CostQuota {
50
64
  return dropped;
51
65
  }
52
66
  }
67
+ const SEP = "\u0000";
68
+ function entryKey(key, bucket) {
69
+ return `${key}${SEP}${bucket}`;
70
+ }
53
71
  //# sourceMappingURL=cost-quota.js.map
@@ -225,7 +225,7 @@ export declare const FAIL_OPEN_TAGS: {
225
225
  };
226
226
  readonly "server.hooks.matcher-unjudged": {
227
227
  readonly cls: "F";
228
- readonly note: "S-995(`hooks/hook-runner.ts` 的 `deadToolMatcherIssue`,fresh 受理面「工具事件 matcher 选中别名而选不中正名 ⇒ 400」判据的**未判**臂;detail = `event=… reason=… matcherChars=…`,不带 matcher 原字节):一只 matcher 没能被判 ⇒ 照旧受理。判据一条顺序、两档共用(7.114.0 修复轮):长度上限 → 空 / `*` → 字面读法(两档都先走、不占预算)→ 正则形。三种 reason 同一个 tag —— `oversize`(两档:超 8 192 字符不读;多租户车道上 hooks 不装配,这一笔只为让超长输入显形)/ `over_budget`(regex 档正则形:一次受理共享的 100 ms 墙钟预算用尽,`node:vm` 的 timeout 打断正则回溯)/ `invalid_regex`(regex 档正则形编不过;确定性的坏配置,运行期另记 `hook_matcher_invalid`,拒它是新检测臂、不在本规则)。**不记**的:literal 档(多租户车道)遇正则形 = 不适用 —— 该车道 hooks 整只不装配,没有被守的对象,不是未判。放行的最坏后果 = **那一只 matcher 没被这条判据守住**:它若真写了别名,照旧永不命中(7.112.0 对正则形一律不判,同一后果);**不是安全闸**(受理面的有效性判 = S-924 的「响亮」,决定不了任何放行 / 拒绝 / 权限;hook 的 deny 语义在运行期 hook-runner)。反方向(越界即拒)会把负载抖动 / 病态但合法的 matcher 变成对合法请求的 400。拒放集合相对 7.113.0 的一处放宽:超过上限的字面别名 matcher 7.113.0 拒、7.114.0 两档未判受理(已披露)。计数让「受理面一直判不了」(攻击者喂灾难正则 / 超长 matcher)显形。";
228
+ readonly note: "S-995(`hooks/hook-runner.ts` 的 `deadToolMatcherIssue`,fresh 受理面「工具事件 matcher 选中别名而选不中正名 ⇒ 400」判据的**未判**臂;detail = `event=… reason=… matcherChars=…`,不带 matcher 原字节):一只 matcher 没能被判 ⇒ 照旧受理。判据一条顺序、两档共用(7.114.0 修复轮):长度上限 → 形只问契约包的 `hookMatcherForm`(literal 形〔名字表 / 空 / `*`〕两档都直接问 `hookMatcherSelects`、不占预算;regex 形 regex 档把同一只契约函数交进 vm 带预算、literal 档不适用;invalid 形 regex 档直接记 `invalid_regex`、literal 档不适用)。三种 reason 同一个 tag —— `oversize`(两档:超 8 192 字符不读;多租户车道上 hooks 不装配,这一笔只为让超长输入显形)/ `over_budget`(regex 档正则形:一次受理共享的 100 ms 墙钟预算用尽,`node:vm` 的 timeout 打断正则回溯)/ `invalid_regex`(regex 档正则形编不过;确定性的坏配置,运行期另记 `hook_matcher_invalid`,拒它是新检测臂、不在本规则)。**不记**的:literal 档(多租户车道)遇正则形 = 不适用 —— 该车道 hooks 整只不装配,没有被守的对象,不是未判。放行的最坏后果 = **那一只 matcher 没被这条判据守住**:它若真写了别名,照旧永不命中(7.112.0 对正则形一律不判,同一后果);**不是安全闸**(受理面的有效性判 = S-924 的「响亮」,决定不了任何放行 / 拒绝 / 权限;hook 的 deny 语义在运行期 hook-runner)。反方向(越界即拒)会把负载抖动 / 病态但合法的 matcher 变成对合法请求的 400。拒放集合相对 7.113.0 的一处放宽:超过上限的字面别名 matcher 7.113.0 拒、7.114.0 两档未判受理(已披露)。计数让「受理面一直判不了」(攻击者喂灾难正则 / 超长 matcher)显形。";
229
229
  };
230
230
  readonly "server.hooks.notice-sink-threw": {
231
231
  readonly cls: "F";
@@ -206,7 +206,7 @@ export const FAIL_OPEN_TAGS = {
206
206
  },
207
207
  "server.hooks.matcher-unjudged": {
208
208
  cls: "F",
209
- note: "S-995(`hooks/hook-runner.ts` 的 `deadToolMatcherIssue`,fresh 受理面「工具事件 matcher 选中别名而选不中正名 ⇒ 400」判据的**未判**臂;detail = `event=… reason=… matcherChars=…`,不带 matcher 原字节):一只 matcher 没能被判 ⇒ 照旧受理。判据一条顺序、两档共用(7.114.0 修复轮):长度上限 → 空 / `*` → 字面读法(两档都先走、不占预算)→ 正则形。三种 reason 同一个 tag —— `oversize`(两档:超 8 192 字符不读;多租户车道上 hooks 不装配,这一笔只为让超长输入显形)/ `over_budget`(regex 档正则形:一次受理共享的 100 ms 墙钟预算用尽,`node:vm` 的 timeout 打断正则回溯)/ `invalid_regex`(regex 档正则形编不过;确定性的坏配置,运行期另记 `hook_matcher_invalid`,拒它是新检测臂、不在本规则)。**不记**的:literal 档(多租户车道)遇正则形 = 不适用 —— 该车道 hooks 整只不装配,没有被守的对象,不是未判。放行的最坏后果 = **那一只 matcher 没被这条判据守住**:它若真写了别名,照旧永不命中(7.112.0 对正则形一律不判,同一后果);**不是安全闸**(受理面的有效性判 = S-924 的「响亮」,决定不了任何放行 / 拒绝 / 权限;hook 的 deny 语义在运行期 hook-runner)。反方向(越界即拒)会把负载抖动 / 病态但合法的 matcher 变成对合法请求的 400。拒放集合相对 7.113.0 的一处放宽:超过上限的字面别名 matcher 7.113.0 拒、7.114.0 两档未判受理(已披露)。计数让「受理面一直判不了」(攻击者喂灾难正则 / 超长 matcher)显形。",
209
+ note: "S-995(`hooks/hook-runner.ts` 的 `deadToolMatcherIssue`,fresh 受理面「工具事件 matcher 选中别名而选不中正名 ⇒ 400」判据的**未判**臂;detail = `event=… reason=… matcherChars=…`,不带 matcher 原字节):一只 matcher 没能被判 ⇒ 照旧受理。判据一条顺序、两档共用(7.114.0 修复轮):长度上限 → 形只问契约包的 `hookMatcherForm`(literal 形〔名字表 / 空 / `*`〕两档都直接问 `hookMatcherSelects`、不占预算;regex 形 regex 档把同一只契约函数交进 vm 带预算、literal 档不适用;invalid 形 regex 档直接记 `invalid_regex`、literal 档不适用)。三种 reason 同一个 tag —— `oversize`(两档:超 8 192 字符不读;多租户车道上 hooks 不装配,这一笔只为让超长输入显形)/ `over_budget`(regex 档正则形:一次受理共享的 100 ms 墙钟预算用尽,`node:vm` 的 timeout 打断正则回溯)/ `invalid_regex`(regex 档正则形编不过;确定性的坏配置,运行期另记 `hook_matcher_invalid`,拒它是新检测臂、不在本规则)。**不记**的:literal 档(多租户车道)遇正则形 = 不适用 —— 该车道 hooks 整只不装配,没有被守的对象,不是未判。放行的最坏后果 = **那一只 matcher 没被这条判据守住**:它若真写了别名,照旧永不命中(7.112.0 对正则形一律不判,同一后果);**不是安全闸**(受理面的有效性判 = S-924 的「响亮」,决定不了任何放行 / 拒绝 / 权限;hook 的 deny 语义在运行期 hook-runner)。反方向(越界即拒)会把负载抖动 / 病态但合法的 matcher 变成对合法请求的 400。拒放集合相对 7.113.0 的一处放宽:超过上限的字面别名 matcher 7.113.0 拒、7.114.0 两档未判受理(已披露)。计数让「受理面一直判不了」(攻击者喂灾难正则 / 超长 matcher)显形。",
210
210
  },
211
211
  "server.hooks.notice-sink-threw": {
212
212
  cls: "F",
@@ -103,15 +103,11 @@ export declare function redactErrorMessage(raw: string | undefined): string | un
103
103
  * 它**不是第二只脱敏器**:零自有规则表、零自有词表,判「什么算凭据」的仍然只有 {@link redactSecrets} 一只。
104
104
  * 本函数只定两件**写回**的事,而这两件事在三条既有纪律面前各只有一个正确答案:
105
105
  *
106
- * ① **认出凭据形才改写字节;没认出就回原串**(codex 对抗复审 r1 [medium],亲核实测后修)。
107
- * `redactSecrets` 自 S-103 起**无条件先剥** `\p{Cf}`,所以 `redactSecrets(x) !== x` 对任何含 Cf 的文本都为真 ——
108
- * 实测:`👨‍👩‍👧`(ZWJ 家庭 emoji)被拆成三个独立 emoji、波斯语 `می‌خواهم`(ZWNJ)被粘成 `میخواهم`。
109
- * 诊断文本上这是成文代价(那一族本就要「先扫描后渲染」);**用户最终答复**上它是每一条含 ZWJ/ZWNJ 的
110
- * 正常答案被永久改坏,而本件对外的承诺逐字是「不含凭据形的文本逐字节不变」。判据用的是本模块**自己成文的
111
- * 那一条**(`redact.ts` 的 `normalizeForRedaction` 顶注:「正确的谓词是 `redactSecrets(x) !== normalizeForRedaction(x)`」),
112
- * 既有消费方 `tool-approval.ts` 的规则车道闸用的也是它 —— 一条谓词两处用,不是新规矩。
113
- * ⚠️ **命中那一次仍然给剥后文本**(S-103 的「先扫描后渲染」不动摇):把零宽字符塞进密钥中间的那条文本,
114
- * 渲染的必须是剥后那份,否则消费端把残片拼回原钥。
106
+ * ① **认出凭据形才改写字节;没认出就回原串** —— 这就是 `redactSecrets` 自己的语义,本函数不另立读法(S-1435 起):
107
+ * 臂只在剥 `\p{Cf}` 后的视图上认凭据,产物按**原文**坐标拼 ⇒ 没有跨度的文本逐字节原样(`👨‍👩‍👧` 的 ZWJ、波斯语
108
+ * `می‌خواهم` 的 ZWNJ 都在),有跨度时只有跨度内的字节被盖 —— 夹在密钥中间的零宽字符随整段密钥一起换成记号,
109
+ * 消费端拿不到可拼回原钥的残片。此前 `redactSecrets` 的产物是剥后文本,本函数为此另比一次「输出 === 剥后原文 ⇒
110
+ * 回原字节」(同一问两处答),已随产物回原文退役。
115
111
  * ② **扫描器抛 ⇒ 登记过的占位(fail-closed),不把整条 run 拖下水**(codex r1 [high],亲核实测后修)。
116
112
  * 立案时 `redactSecrets` 的 JSON 秘密字段臂含多字符循环体(`(?:\\.[^"\\]*)*`),实测
117
113
  * `{"password":"<400 万个 \\n 转义对>"}`(8MB)抛 `RangeError`。`result` 接进来之前这条路不存在;接进来之后,
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { redactSecrets, normalizeForRedaction } from "../trace/redact.js";
2
+ import { redactSecrets } from "../trace/redact.js";
3
3
  import { isProviderEchoShape } from "@sema-agent/core";
4
4
  import { isModelTextLedgerEventType, isParkLedgerEventType } from "../trace/ledger-events.js";
5
5
  import { isUnsuccessfulRunStatus } from "../plugins/store-contracts.js";
@@ -86,12 +86,7 @@ export function redactErrorMessage(raw) {
86
86
  }
87
87
  export function redactModelText(raw) {
88
88
  try {
89
- const out = redactSecrets(raw);
90
- if (out === raw)
91
- return raw;
92
- if (out === normalizeForRedaction(raw))
93
- return raw;
94
- return out;
89
+ return redactSecrets(raw);
95
90
  }
96
91
  catch (err) {
97
92
  recordFailOpen("server.run-terminal.redact-threw", `model-text len=${raw.length} err=${errorText(err)}`);
@@ -144,7 +144,7 @@ export const TIDB_APPROVAL_ASK_STATEMENTS = [
144
144
  -- rule_command 与 permission_rule_approval.command 同款 LONGTEXT:命令行没有入口上限,VARCHAR
145
145
  -- 会造一个静默截断面,而截断过的命令与原命令是两条不同的命令(覆盖门会据一条我们自己改过的命令判)。
146
146
  rule_command LONGTEXT NULL,
147
- -- rule_scope_root:root 的真上界 = task-cwd.ts 的 MAX_CWD_CHARS(4096,入口 isValidCwd 硬拒更长者),
147
+ -- rule_scope_root:root 的真上界 = 主机路径形判的 ABSOLUTE_HOST_PATH_MAX(4096,入口 isAbsoluteHostPath 硬拒更长者),
148
148
  -- 宽度与那把尺同源 —— 与 MAX_RULE_SCOPE_CHARS 的取值法(由铸侧的尺派生)逐字同精神。
149
149
  rule_scope_root VARCHAR(4096) NULL,
150
150
  schema_version INT NOT NULL,
@@ -1,5 +1,5 @@
1
1
  import type { AcquiredSession, PlacedSessionRow, RetentionDeclaration, SessionPlacementRecord, SessionStore, SessionTreeEntry } from "@sema-agent/core";
2
- import type { SessionListItem } from "../security.js";
2
+ import type { OwnerAwareSessionStore, SessionListItem } from "../security.js";
3
3
  import type { StagingHandle } from "../session-sync-kernel.js";
4
4
  import { type PlacementAcquireOpts } from "./session-placement.js";
5
5
  import type { InstructionSourceStore } from "../instruction-source-notice.js";
@@ -57,7 +57,12 @@ export declare class CachingSessionStore implements SessionStore {
57
57
  }) => Promise<SessionListItem[]>;
58
58
  readonly fork?: (sourceId: string, owner: string | null) => Promise<string | null>;
59
59
  readonly deleteSession?: (sessionId: string, owner: string | null) => Promise<boolean>;
60
- /** Write-once auto-title (SQL twins + local sidecar carry it; pure pass-through). */
60
+ /** The session row's list projection for one id + its writable bits (`title` / `pinned`) — DURABLE, pure pass-through
61
+ * (they read / write `session_meta` / the local sidecars, never the warm cache). 🔴 Must forward (the retention / placements
62
+ * lesson): the production default wraps the real store in this class, and the route's fact `sessionPatch` reads THIS object. */
63
+ readonly sessionSummaryOf?: (sessionId: string) => Promise<SessionListItem | undefined>;
64
+ readonly patchSessionMeta?: OwnerAwareSessionStore["patchSessionMeta"];
65
+ /** The auto-titler's NULL-only title write (SQL twins + local sidecar carry it; pure pass-through). */
61
66
  readonly setTitleIfNull?: (sessionId: string, title: string) => Promise<boolean>;
62
67
  readonly probeTitle?: (sessionId: string) => Promise<"none" | "untitled" | "titled">;
63
68
  /** S-690 —— 换源通告的基线面(`session_meta.instruction_source` / local sidecar),纯转发、不经暖缓存。
@@ -11,6 +11,8 @@ export class CachingSessionStore {
11
11
  listSessions;
12
12
  fork;
13
13
  deleteSession;
14
+ sessionSummaryOf;
15
+ patchSessionMeta;
14
16
  setTitleIfNull;
15
17
  probeTitle;
16
18
  readInstructionSource;
@@ -51,6 +53,10 @@ export class CachingSessionStore {
51
53
  this.register = ownerAware.register.bind(inner);
52
54
  if (ownerAware.listSessions)
53
55
  this.listSessions = ownerAware.listSessions.bind(inner);
56
+ if (ownerAware.sessionSummaryOf)
57
+ this.sessionSummaryOf = ownerAware.sessionSummaryOf.bind(inner);
58
+ if (ownerAware.patchSessionMeta)
59
+ this.patchSessionMeta = ownerAware.patchSessionMeta.bind(inner);
54
60
  if (ownerAware.setTitleIfNull)
55
61
  this.setTitleIfNull = ownerAware.setTitleIfNull.bind(inner);
56
62
  if (ownerAware.probeTitle)
@@ -22,7 +22,7 @@ export const TIDB_DEVICE_STATEMENTS = [
22
22
  -- Ed25519 公钥 b64(44 字符);VARBINARY 因为它参与 UNIQUE 与身份比对。
23
23
  pubkey VARBINARY(128) NOT NULL,
24
24
  pubkey_rotated_at DATETIME(3) NULL,
25
- -- 设备工作区根:上界取 task-cwd.ts 的 MAX_CWD_CHARS 家族(4096 过宽、1024 是路径的实用上界)。
25
+ -- 设备工作区根:上界取主机路径形判 ABSOLUTE_HOST_PATH_MAX 家族(4096 过宽、1024 是路径的实用上界)。
26
26
  workspace_root VARCHAR(1024) NOT NULL,
27
27
  workspace_root_rev BIGINT NOT NULL DEFAULT 0,
28
28
  status VARCHAR(16) NOT NULL DEFAULT 'active',
@@ -1,4 +1,5 @@
1
1
  import { recordFailOpen } from "../observability/fail-open.js";
2
+ import { readLiveColumns } from "./schema-live-columns.js";
2
3
  function rowsOf(res) {
3
4
  if (Array.isArray(res)) {
4
5
  const first = res[0];
@@ -30,6 +31,27 @@ const refusal = (spec, unique, what) => `ensureIndex: PostgreSQL 索引 ${spec.n
30
31
  `CREATE ${unique}INDEX CONCURRENTLY ${quoteId("pg", spec.name)} ON ${quoteId("pg", spec.table)} (${columnList("pg", spec)});` +
31
32
  " 常见根因 = 池级 statement_timeout(DB_QUERY_TIMEOUT_MS)把构建切断(见 cause),或 UNIQUE 索引撞上存量重复行。";
32
33
  export async function ensureIndex(q, dialect, spec) {
34
+ try {
35
+ await applyIndex(q, dialect, spec);
36
+ }
37
+ catch (err) {
38
+ let live;
39
+ try {
40
+ live = await readLiveColumns(async (sql) => rowsOf(await q(sql)), dialect);
41
+ }
42
+ catch {
43
+ throw err;
44
+ }
45
+ if (indexColumnMissing(live, spec))
46
+ return;
47
+ throw err;
48
+ }
49
+ }
50
+ function indexColumnMissing(live, spec) {
51
+ const columns = live.get(spec.table);
52
+ return columns !== undefined && spec.columns.some((c) => !columns.has(c));
53
+ }
54
+ async function applyIndex(q, dialect, spec) {
33
55
  switch (dialect) {
34
56
  case "mysql": {
35
57
  const present = rowsOf(await q("SELECT 1 AS present FROM information_schema.statistics WHERE table_schema = DATABASE() " +
@@ -1,6 +1,6 @@
1
1
  import { type UsageRow } from "../usage-analytics.js";
2
2
  import type { TaskStatus } from "@sema-agent/core";
3
- import { StoreBootRefusalError, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
3
+ import { StoreBootRefusalError, type CreateRunOutcome, type NewRunMeta, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
4
4
  import type { RunStoreCheckpointProbe } from "./memory-run-store.js";
5
5
  import type { LedgerEventType } from "../trace/ledger-events.js";
6
6
  /**
@@ -29,6 +29,8 @@ export declare class FileRunStore {
29
29
  private readonly runs;
30
30
  /** sessionId → taskId — the single-active-run claim (the task_active table; index of active/*). */
31
31
  private readonly active;
32
+ /** idemKey → taskId — the file twin of `uq_task_run_idem` (rebuilt from run.json at hydrate; one writer per data root). */
33
+ private readonly byIdem;
32
34
  /** taskId → events (kept seq-sorted on read), the event-log index. */
33
35
  private readonly events;
34
36
  /** taskId → the open append log (one O_APPEND fd per run, lazily opened, held for lifetime). */
@@ -91,19 +93,20 @@ export declare class FileRunStore {
91
93
  /** Commit a RunRow to disk (whole-file atomic replace) AND the index — the single write path for every status change. */
92
94
  private persist;
93
95
  private eventLogFor;
94
- /** Create a run iff the session has no active run (the task_active unique-key claim). The claim file is created with
95
- * writeThenLink (atomic): if it already exists (EEXIST) the session has an active run → lose, return the holder. */
96
- createRun(taskId: string, sessionId: string, owner: string | null, instanceId: string, meta?: {
97
- jobId?: string | null;
98
- source?: string | null;
99
- objectivePreview?: string | null;
100
- }): Promise<{
101
- ok: true;
102
- } | {
103
- ok: false;
104
- activeTaskId: string;
105
- }>;
106
- /** [ref] —— createRun 临界区的后半段:落 claim 索引 + run 行(两者一体,行写失败即回滚 claim)。 */
96
+ /** Create a run iff the session has no active run (the task_active unique-key claim) AND no submission key of this
97
+ * request already names a row (submission idempotency: `idemKey` or a caller-minted `taskId`) — the file twin of the
98
+ * SQL store's **atomic win or observe** ({@link CreateRunOutcome}). The claim file is created with writeThenLink
99
+ * (atomic): if it already exists (EEXIST) the session has an active run → lose, return the holder — unless a key of
100
+ * this submission names a row, which is then OBSERVED (a same-key retry whose original still holds the session is a
101
+ * replay, not a busy session). */
102
+ createRun(taskId: string, sessionId: string, owner: string | null, instanceId: string, meta?: NewRunMeta): Promise<CreateRunOutcome>;
103
+ /** The row a submission's keys name — `idemKey` first, then `taskId` (the two keys of one law; see {@link CreateRunOutcome}). */
104
+ private submittedRow;
105
+ /** Release a claim this create just took (index + claim file) — the rollback half of the critical section. */
106
+ private releaseFreshClaim;
107
+ /** [ref] —— createRun 临界区的后半段:落 claim 索引 + run 行(两者一体,行写失败即回滚 claim)。
108
+ * Synchronous from the key check to the index write ⇒ two same-key creates in this process (different sessions,
109
+ * different claim locks) cannot both pass the check: the loser observes the winner's row. */
107
110
  private commitNewRun;
108
111
  requestCancel(taskId: string, owner: string | null): Promise<boolean>;
109
112
  isCancelRequested(taskId: string, owner: string | null): Promise<boolean>;
@@ -128,6 +131,13 @@ export declare class FileRunStore {
128
131
  markResuming(taskId: string, instanceId: string): Promise<boolean>;
129
132
  getActiveTaskId(sessionId: string): Promise<string | undefined>;
130
133
  getRun(taskId: string): Promise<RunRecord | undefined>;
134
+ /** Undo THIS submission's key binding (`idem_key` → NULL) — called only where the submit surface refuses the submission
135
+ * before any work and terminalizes the row it just claimed (the sync leg's zero-work refusal, a device-placement reject):
136
+ * the error answer invites a retry, and a retry with the same key must run, not replay the refusal. The row stays (it
137
+ * is the refusal's record); only the key is freed. */
138
+ releaseIdemKey(taskId: string): Promise<void>;
139
+ /** The run a submission's scoped `Idempotency-Key` names (the file twin of `uq_task_run_idem`), if any. */
140
+ getRunByIdem(idemKey: string): Promise<RunRecord | undefined>;
131
141
  /** Runs newest-first, keyset on (createdAt, taskId) DESC; optional exact-match filters (owner is exact, like the SQL). */
132
142
  listRuns(opts: {
133
143
  status?: string;
@@ -55,6 +55,7 @@ export class FileRunStore {
55
55
  tmpDir;
56
56
  runs = new Map();
57
57
  active = new Map();
58
+ byIdem = new Map();
58
59
  events = new Map();
59
60
  eventLogs = new Map();
60
61
  checkpointProbe;
@@ -97,7 +98,7 @@ export class FileRunStore {
97
98
  return join(this.activeDir, sanitizePathComponent(sessionId));
98
99
  }
99
100
  toRow(d) {
100
- return { ...d, createdAt: new Date(d.createdAt), updatedAt: new Date(d.updatedAt) };
101
+ return { ...d, idemKey: typeof d.idemKey === "string" ? d.idemKey : null, createdAt: new Date(d.createdAt), updatedAt: new Date(d.updatedAt) };
101
102
  }
102
103
  toDisk(r) {
103
104
  return { ...r, createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString() };
@@ -189,7 +190,10 @@ export class FileRunStore {
189
190
  const disk = singleRecordOf(this.replay(jsonPath, RUN_ROW_SHAPE));
190
191
  if (!disk)
191
192
  continue;
192
- this.runs.set(disk.taskId, this.toRow(disk));
193
+ const row = this.toRow(disk);
194
+ this.runs.set(disk.taskId, row);
195
+ if (row.idemKey !== null)
196
+ this.byIdem.set(row.idemKey, row.taskId);
193
197
  const eventsPath = join(dir, "events.jsonl");
194
198
  const recs = this.replay(eventsPath, EVENT_RECORD_SHAPE).records;
195
199
  if (recs.length) {
@@ -279,14 +283,34 @@ export class FileRunStore {
279
283
  return log;
280
284
  }
281
285
  async createRun(taskId, sessionId, owner, instanceId, meta = {}) {
286
+ const idemKey = meta.idemKey ?? null;
282
287
  return this.withClaimLock(sessionId, async () => {
283
288
  const acquired = await this.acquireClaim(sessionId, taskId);
284
- if (!acquired.ok)
285
- return acquired;
286
- return this.commitNewRun(taskId, sessionId, owner, instanceId, meta);
289
+ if (!acquired.ok) {
290
+ const existing = this.submittedRow(taskId, idemKey);
291
+ return existing ? { ok: false, existing: this.toRecord(existing) } : acquired;
292
+ }
293
+ return this.commitNewRun(taskId, sessionId, owner, instanceId, meta, idemKey);
287
294
  });
288
295
  }
289
- commitNewRun(taskId, sessionId, owner, instanceId, meta) {
296
+ submittedRow(taskId, idemKey) {
297
+ const byKey = idemKey !== null ? this.byIdem.get(idemKey) : undefined;
298
+ return (byKey !== undefined ? this.runs.get(byKey) : undefined) ?? this.runs.get(taskId);
299
+ }
300
+ releaseFreshClaim(sessionId) {
301
+ this.active.delete(sessionId);
302
+ try {
303
+ rmSync(this.activePath(sessionId), { force: true });
304
+ }
305
+ catch {
306
+ }
307
+ }
308
+ commitNewRun(taskId, sessionId, owner, instanceId, meta, idemKey) {
309
+ const existing = this.submittedRow(taskId, idemKey);
310
+ if (existing) {
311
+ this.releaseFreshClaim(sessionId);
312
+ return { ok: false, existing: this.toRecord(existing) };
313
+ }
290
314
  this.active.set(sessionId, taskId);
291
315
  const now = new Date();
292
316
  try {
@@ -302,6 +326,7 @@ export class FileRunStore {
302
326
  source: meta.source ?? null,
303
327
  instanceId,
304
328
  objectivePreview: meta.objectivePreview ?? null,
329
+ idemKey,
305
330
  createdAt: now,
306
331
  updatedAt: now,
307
332
  cancelRequested: false,
@@ -309,14 +334,11 @@ export class FileRunStore {
309
334
  });
310
335
  }
311
336
  catch (e) {
312
- this.active.delete(sessionId);
313
- try {
314
- rmSync(this.activePath(sessionId), { force: true });
315
- }
316
- catch {
317
- }
337
+ this.releaseFreshClaim(sessionId);
318
338
  throw e;
319
339
  }
340
+ if (idemKey !== null)
341
+ this.byIdem.set(idemKey, taskId);
320
342
  return { ok: true };
321
343
  }
322
344
  async requestCancel(taskId, owner) {
@@ -453,6 +475,20 @@ export class FileRunStore {
453
475
  const r = this.runs.get(taskId);
454
476
  return r ? this.toRecord(r) : undefined;
455
477
  }
478
+ async releaseIdemKey(taskId) {
479
+ const r = this.runs.get(taskId);
480
+ if (!r || r.idemKey === null)
481
+ return;
482
+ const key = r.idemKey;
483
+ this.persist({ ...r, idemKey: null });
484
+ if (this.byIdem.get(key) === taskId)
485
+ this.byIdem.delete(key);
486
+ }
487
+ async getRunByIdem(idemKey) {
488
+ const taskId = this.byIdem.get(idemKey);
489
+ const r = taskId !== undefined ? this.runs.get(taskId) : undefined;
490
+ return r ? this.toRecord(r) : undefined;
491
+ }
456
492
  async listRuns(opts) {
457
493
  if (opts.cursor && Number.isNaN(Date.parse(opts.cursor.createdAt)))
458
494
  return [];
@@ -519,6 +555,7 @@ export class FileRunStore {
519
555
  lastRunId: latest.taskId,
520
556
  ...(typeof latestRunId === "string" && latestRunId !== "" ? { lastTaskRunId: latestRunId } : {}),
521
557
  title: null,
558
+ pinned: false,
522
559
  });
523
560
  }
524
561
  return summaries
@@ -871,6 +908,9 @@ export class FileRunStore {
871
908
  this.eventLogs.delete(taskId);
872
909
  }
873
910
  this.events.delete(taskId);
911
+ const dropped = this.runs.get(taskId);
912
+ if (dropped?.idemKey != null && this.byIdem.get(dropped.idemKey) === taskId)
913
+ this.byIdem.delete(dropped.idemKey);
874
914
  this.runs.delete(taskId);
875
915
  const dir = this.runDir(taskId);
876
916
  if (existsSync(dir)) {
@@ -24,7 +24,7 @@ export interface LeaderRunRecord {
24
24
  export interface NewLeaderRun {
25
25
  objective: string;
26
26
  owner: string | null;
27
- /** 已 scope 过的幂等键的 sha256 hex(见 {@link leaderIdemKey});null = 不去重。 */
27
+ /** 已 scope 过的幂等键(唯一铸法 `idemKeyOf(raw, caller, "leader")`,`http/idempotency.ts`;sha256 hex 定宽 64);null = 不去重。 */
28
28
  idemKey: string | null;
29
29
  }
30
30
  /** 终局补丁 —— `finishRun` 一次写完(状态 + 结果/错误 + 完成时刻)。 */
@@ -35,16 +35,6 @@ export interface LeaderRunTerminal {
35
35
  }
36
36
  /** `objective_preview` 的字符上限,与 DDL 的 VARCHAR(160) 同源(`task_run.objective_preview` 先例同宽)。 */
37
37
  export declare const LEADER_OBJECTIVE_PREVIEW_CHARS = 160;
38
- /**
39
- * 幂等键的**落库形** = `sha256(scope \x1f raw)` 的 hex(64 字符,定宽)。
40
- *
41
- * 为什么 hash 而不是像 bake 店那样存 scope 过的明文:明文形的列宽(VARCHAR(255))与「principal(≤190)
42
- * + 调用方自选的 raw 键(≤255)」的上界对不上 —— 溢出时 PG 报 `value too long`、非严格 MySQL **静默截断**,
43
- * 而截断一个去重键的后果是**两个不同的请求折叠成同一次 leader run**(= 一次该跑的 run 被静默吞掉)。
44
- * 定宽 hash 让这条路径按构造不存在;顺带,库里也不再留调用方的原始键字面量。
45
- * (碰撞面:sha256,不在工程风险量级。)
46
- */
47
- export declare function leaderIdemKey(raw: string, owner: string | null): string;
48
38
  /** MySQL 协议方言的建表语句(真源;由 `tidb-pool.ts` 展开进中央 SCHEMA_STATEMENTS)。 */
49
39
  export declare const TIDB_LEADER_RUN_STATEMENTS: readonly string[];
50
40
  /** PG 方言的建表语句(MySQL 孪生的逐条翻译:JSON→JSONB,逐列 `COLLATE "C"`,内联 KEY→独立 CREATE INDEX)。 */
@@ -1,5 +1,4 @@
1
1
  import { uuidv7 } from "@sema-agent/core";
2
- import { createHash } from "node:crypto";
3
2
  import { isDupKeyError } from "./sql-errors.js";
4
3
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
5
4
  import { decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readNonNullJsonValueColumn } from "./sql-json-column.js";
@@ -8,13 +7,10 @@ import { buildObjectivePreview } from "../user-authored-text.js";
8
7
  export const LEADER_RUN_TABLE = "leader_run";
9
8
  const LEADER_RUN_STATUSES = new Set(["running", "completed", "failed", "needs_human"]);
10
9
  export const LEADER_OBJECTIVE_PREVIEW_CHARS = 160;
11
- export function leaderIdemKey(raw, owner) {
12
- return createHash("sha256").update(`${owner ?? ""}\x1f${raw}`).digest("hex");
13
- }
14
10
  export const TIDB_LEADER_RUN_STATEMENTS = [
15
11
  `CREATE TABLE IF NOT EXISTS ${LEADER_RUN_TABLE} (
16
12
  leader_run_id VARCHAR(64) NOT NULL,
17
- -- idem_key:提交幂等的**唯一**执法点(sha256 hex,定宽 64;见 leaderIdemKey 的头注)。NULL = 调用方
13
+ -- idem_key:提交幂等的**唯一**执法点(sha256 hex,定宽 64;铸法 = http/idempotency.ts 的 idemKeyOf,族 leader)。NULL = 调用方
18
14
  -- 没带 Idempotency-Key ⇒ 不参与去重(MySQL/PG 的 UNIQUE 都不约束 NULL,两方言同形)。
19
15
  idem_key VARCHAR(64) NULL,
20
16
  -- owner:BL-4 提交者(null = 匿名/单租)。GET 的属主门读它 —— 非属主 404(不是 403),不给存在性预言机。
@@ -1,5 +1,6 @@
1
1
  import { FileSessionRepo, type AcquiredSession, type SessionStore, type SessionRepo, type SessionTreeEntry } from "@sema-agent/core";
2
2
  import type { SessionRunRollupSource, SessionSummary } from "./store-contracts.js";
3
+ import type { SessionRowPatch } from "../session-row.js";
3
4
  import { type Logger } from "../observability/logger.js";
4
5
  import { type SessionFace } from "../session-incarnation.js";
5
6
  import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
@@ -49,10 +50,14 @@ export declare class LocalSessionStore implements SessionStore {
49
50
  private lastWarnAt;
50
51
  /** Absolute path to the lastActivity JSON sidecar, or undefined when not persisting (in-memory-only deployment). */
51
52
  private readonly sidecarPath?;
52
- /** Auto-title map (`session-titles.json`). Separate file from the lastActivity sidecar ON PURPOSE (that format is a
53
- * #10 clobber area); titles are write-once + ultra-low-rate (one per session, ever). Cross-instance sharing: see
54
- * {@link SidecarMap}. */
53
+ /** Title map (`session-titles.json`) — the local twin of the SQL `session_meta.title` column: one cell, two writers
54
+ * (the user's {@link patchSessionMeta}, unconditional; the auto-titler's {@link setTitleIfNull}, fills an absent cell
55
+ * only — writer order: `src/session-row.ts`). Separate file from the lastActivity sidecar ON PURPOSE (that format is a
56
+ * #10 clobber area); ultra-low-rate. Cross-instance sharing: see {@link SidecarMap}. */
55
57
  private readonly titles;
58
+ /** Pin map (`session-pinned.json`, {@link readPinnedSidecar}) — the local twin of `session_meta.pinned`. Its own file for
59
+ * the same reason titles have theirs (one small map per concern, no format coupling); absent key = not pinned. */
60
+ private readonly pinned;
56
61
  /** S-690 — instruction-source baselines (`session-instruction-sources.json`): the chain member the session's previous
57
62
  * run selected, encoded like the SQL twins' `session_meta.instruction_source` column ('' = none; missing key = never
58
63
  * recorded). Its own file for the same reason titles have theirs: one small map per concern, no format coupling. */
@@ -103,10 +108,28 @@ export declare class LocalSessionStore implements SessionStore {
103
108
  /** Cheap pre-LLM probe (local twin). The map write needs no session_meta row, so there is
104
109
  * no "none" (register-lag) dimension here — two states only. */
105
110
  probeTitle(sessionId: string): Promise<"none" | "untitled" | "titled">;
106
- /** Write-once auto-title (the local twin of the SQL setTitleIfNull). Persists the whole
107
- * (small) map via a SERIALIZED tmp+rename snapshot; a write failure keeps the in-memory title (degrades to
108
- * re-title after restart). */
111
+ /** The auto-titler's write (the local twin of the SQL setTitleIfNull): lands only on an absent cell — a title already
112
+ * there (the user's included) wins. Persists the whole (small) map via a SERIALIZED tmp+rename snapshot; a write
113
+ * failure keeps the in-memory title (degrades to re-title after restart). */
109
114
  setTitleIfNull(sessionId: string, title: string): Promise<boolean>;
115
+ /**
116
+ * 会话行的可写位 —— 用户标题 / 置顶的**唯一**写口(SQL 孪生 `patchSessionMeta` 的 local 对位;写者次序见 `src/session-row.ts`)。
117
+ * 只写列表面上的会话(会话仓里有它),且只写调用方验过的那一位住客(`face.incarnation` 与会话元数据那一格的化身相等 —— SQL 孪生的
118
+ * `AND incarnation = ?`):住客判在每只边档 `commit` 的写链节里、对着那一刻的元数据格判(判、写、落盘后才发布),读回同一判据
119
+ * ⇒ 回执是那一位住客的列表行,或 `undefined`(期间被删 / 被谁重新登记 —— 什么都没写)。
120
+ * `title` 串 ⇒ 无条件写进那一格(自动标题器的 {@link setTitleIfNull} 见格在就不写 ⇒ 用户那笔恒留下);`title: null` ⇒ 删那一格(清回自动标题);
121
+ * `pinned:true` 写 `true`、`false` 删键。
122
+ * 落不了盘 ⇒ 抛、内存不动(用户的一笔写,不做「内存里先记着」的降级)。⚠️ 两键同一体时是两只文件、两次落盘:第二只落不了时第一只已在盘上
123
+ * (SQL 孪生是一条 UPDATE);抛出的错点名哪一只,重发同一体收敛。`lastActivity` 不动(改名不是活动 —— 列表的新近序不变)。
124
+ */
125
+ patchSessionMeta(sessionId: string, patch: SessionRowPatch, face: SessionFace): Promise<SessionSummary | undefined>;
126
+ /** 会话元数据那一格此刻坐的是不是 `face` 那一位住客(同步读已发布的格;调用方先 `meta.ensureLoaded()`)。 */
127
+ private occupiedBy;
128
+ /** ONE session's list row — {@link listSessions}' projection ({@link summaryOf}, the same synthesis) for one id;
129
+ * `undefined` = not a row of the list face (not in the session repo). */
130
+ sessionSummaryOf(sessionId: string): Promise<SessionSummary | undefined>;
131
+ /** 会话仓里这只会话的列表元数据(列表面上的行 = `repo.list()` 里的一项)。 */
132
+ private listedMeta;
110
133
  /** S-690 —— 换源通告的基线读(local 孪生;三态读法与 SQL 两孪生的 `session_meta.instruction_source` 同一只编解码)。 */
111
134
  readInstructionSource(sessionId: string): Promise<InstructionSourceBaseline>;
112
135
  /**
@@ -224,6 +247,12 @@ export declare class LocalSessionStore implements SessionStore {
224
247
  limit: number;
225
248
  q?: string;
226
249
  }): Promise<SessionSummary[]>;
250
+ /** 列表行要读的边档一次装齐(lastActivity / 标题 / 置顶 / 会话元数据);各自记忆化,只有第一次真读盘。 */
251
+ private loadRowSidecars;
252
+ /** 列表行上的属主:那一格的属主(没有格 / 属主从没被记下 ⇒ null —— 与 `ownerOf` 同答;列表不补铸缺的格,`ownerOf` / `sessionFaceOf` 在第一次读时补)。 */
253
+ private ownerCellOf;
254
+ /** 一只会话的列表行 —— {@link listSessions} 与 {@link sessionSummaryOf} 的**同一只**合成(字段来源逐位见 listSessions 的头注)。调用方先 {@link loadRowSidecars}。 */
255
+ private summaryOf;
227
256
  /** S-1371:run 店里每个会话的 run 投影(按 sessionId 索引)。
228
257
  * **一次读**:run 店的 `listSessions` 本来就按会话聚合全账(无会话过滤口),这里取全量再按会话仓的 id 查表 —— 比逐会话 N 次读便宜,
229
258
  * 也不在会话店里另存一份 run 账。读失败 ⇒ 抛(不吞成 `0 / ""`:那会把「读不到」说成「没跑过」)。 */