@sema-agent/server 7.9.0 → 7.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/USAGE.md CHANGED
@@ -413,6 +413,12 @@ curl -s http://<host>:8090/v1/runs/<taskId> -H 'x-agent-principal: user:42' #
413
413
  > 不是裸主机名——服务端直接拼 `${GIT_API_BASEURL}/api/v1/...` 发请求,裸 `git.example.com` 会拼出非法 URL 而 fetch 失败。
414
414
  > 末尾不要带 `/`,也不要把 `/api/v1` 写进来。
415
415
 
416
+ > **`GIT_API_KIND`(7.10.0 起)**:Git host 方言,闭集 `gitea`(缺省)/ `github`。GitHub 托管仓
417
+ > (github.com 或 GHE)设 `GIT_API_KIND=github`,此时 `GIT_API_BASEURL` 写 **API 根**(github.com 用
418
+ > `https://api.github.com`;GHE 用 `https://ghe.example.com/api/v3`),token 用 fine-grained PAT
419
+ > (仅 contents/pull-requests 只读)。未知词拒启;`GIT_API_BASEURL` 指向 github.com 族而 kind 仍是
420
+ > gitea 也拒启并指路(Gitea 形状对 GitHub 恒 404,启动即报优于运行时逐调用 404)。
421
+
416
422
  > **升级注记(7.7.0)**:本版起 `code-review` / `scan` / `team` / `toolset: repo-readonly|none` 的场景改跑
417
423
  > 无执行环境的引擎。**升级前**若还有这些场景的 durable 挂起任务(需同时满足:配了 `REMOTE_EXEC`、开了
418
424
  > `DURABLE_APPROVAL`、任务已 park),它们在新版上续跑会以 409 `checkpoint.unsupported_version` 响亮失败
@@ -167,7 +167,7 @@ export declare function isAncestorFoldMint(ask: AskRow): boolean;
167
167
  * 走到 hash 这一层才把缺席记成**单铸** `single_mint`(见 {@link GateMatchOutcome})。
168
168
  *
169
169
  * 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按
170
- * `created_at ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
170
+ * `created_at_ms ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
171
171
  * 一个还能被 resume 的坐标上,对壳更有用。
172
172
  */
173
173
  export declare function classifyGateMatch(ask: AskRow, candidates: readonly CheckpointAskCandidate[]): GateMatchOutcome;
@@ -84,7 +84,7 @@ export function isAncestorFoldMint(ask) {
84
84
  * 走到 hash 这一层才把缺席记成**单铸** `single_mint`(见 {@link GateMatchOutcome})。
85
85
  *
86
86
  * 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按
87
- * `created_at ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
87
+ * `created_at_ms ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
88
88
  * 一个还能被 resume 的坐标上,对壳更有用。
89
89
  */
90
90
  export function classifyGateMatch(ask, candidates) {
@@ -244,12 +244,12 @@ export function startReapers(ctx) {
244
244
  .then(reapCount("runs_reaped_total", { kind: "stale" }))
245
245
  .then(() => runsReapStaleGuard.onSuccess(), runsReapStaleGuard.onError);
246
246
  // Durable F4 (design/45) + design/80 §3 inv#3 crash-safe backstop: CAS-expire checkpoints past their
247
- // deadline OR their absolute terminal_at backstop (≈ deny), then fail the suspended run rows whose
247
+ // deadline OR their absolute terminal_at_ms backstop (≈ deny), then fail the suspended run rows whose
248
248
  // checkpoint was thereby expired (release task_active = unlock the session). These run EVERY tick,
249
- // REGARDLESS of APPROVAL_TIMEOUT_SEC — terminal_at (stamped at put, never before an explicit deadline)
249
+ // REGARDLESS of APPROVAL_TIMEOUT_SEC — terminal_at_ms (stamped at put, never before an explicit deadline)
250
250
  // bounds even a NULL-deadline pending checkpoint, so the backstop is the always-on safety net (it was
251
251
  // inert when nested under the approvalTimeoutSec>0 guard — adversarial finding). The run-row half is
252
- // checkpoint-STATE-driven (not a uniform timer) so it aligns with the per-row terminal_at. Global +
252
+ // checkpoint-STATE-driven (not a uniform timer) so it aligns with the per-row terminal_at_ms. Global +
253
253
  // idempotent across replicas, no election.
254
254
  if (checkpointStore) {
255
255
  // 轴A #5 注释落档(1.254):core CheckpointStore.reap 契约把弃置臂的 unpin 义务派给部署 reaper——
@@ -13,8 +13,42 @@ export interface RepoCoords {
13
13
  }
14
14
  /** Parse `owner/name` or a repo URL (e.g. https://git.example.com/some-org/some-repo.git). */
15
15
  export declare function parseRepo(input: string): RepoCoords;
16
+ import type { GitApiKind } from "../git-api-kind.js";
17
+ export { GIT_API_KINDS, isGitApiKind, type GitApiKind } from "../git-api-kind.js";
18
+ /** The read-only surface the repo tools are built over — one implementation per Git-host dialect. */
19
+ export interface RepoReadClient {
20
+ defaultBranch(coords: RepoCoords): Promise<string>;
21
+ tree(coords: RepoCoords, ref: string): Promise<string[]>;
22
+ readFile(coords: RepoCoords, path: string, ref: string): Promise<string>;
23
+ pullDiff(coords: RepoCoords, index: number): Promise<string>;
24
+ }
25
+ export declare function createRepoClient(kind: GitApiKind, baseUrl: string, token?: string, fetchImpl?: typeof fetch): RepoReadClient;
16
26
  /** Thin read-only Gitea API client. Built once at startup; bound to a repo per task. */
17
- export declare class GiteaClient {
27
+ export declare class GiteaClient implements RepoReadClient {
28
+ private readonly baseUrl;
29
+ private readonly token?;
30
+ private readonly fetchImpl;
31
+ constructor(baseUrl: string, token?: string | undefined, fetchImpl?: typeof fetch);
32
+ private api;
33
+ defaultBranch({ owner, repo }: RepoCoords): Promise<string>;
34
+ tree(coords: RepoCoords, ref: string): Promise<string[]>;
35
+ readFile(coords: RepoCoords, path: string, ref: string): Promise<string>;
36
+ pullDiff(coords: RepoCoords, index: number): Promise<string>;
37
+ }
38
+ /**
39
+ * Thin read-only GitHub (github.com / GHE) API client — same four-call surface as `GiteaClient`.
40
+ *
41
+ * Dialect differences (each one is why `GiteaClient` alone 404s against GitHub):
42
+ * - base URL is the API root itself (`https://api.github.com`, GHE `https://ghe.example/api/v3`) —
43
+ * no `/api/v1` prefix is appended;
44
+ * - auth is `Authorization: Bearer <token>` (Gitea uses `token <token>`);
45
+ * - a PR's unified diff comes from `/pulls/{n}` via `Accept: application/vnd.github.v3.diff`
46
+ * (Gitea uses a `.diff` URL suffix);
47
+ * - the tree API has no `per_page` cap — GitHub returns up to its own limit and reports overflow via
48
+ * `truncated`; we additionally cap client-side at MAX_TREE_ENTRIES so the model-facing bound is the
49
+ * same for both dialects.
50
+ */
51
+ export declare class GitHubClient implements RepoReadClient {
18
52
  private readonly baseUrl;
19
53
  private readonly token?;
20
54
  private readonly fetchImpl;
@@ -26,5 +60,5 @@ export declare class GiteaClient {
26
60
  pullDiff(coords: RepoCoords, index: number): Promise<string>;
27
61
  }
28
62
  /** Build the read-only repo tool set bound to one repo (cheap per-task closure; the client is shared). */
29
- export declare function repoToolsFor(client: GiteaClient, coords: RepoCoords): ToolSpec[];
63
+ export declare function repoToolsFor(client: RepoReadClient, coords: RepoCoords): ToolSpec[];
30
64
  //# sourceMappingURL=repo-tools.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import { Type } from "typebox";
2
+ import { z } from "zod";
2
3
  /** Parse `owner/name` or a repo URL (e.g. https://git.example.com/some-org/some-repo.git). */
3
4
  export function parseRepo(input) {
4
5
  const s = input.trim().replace(/\.git$/, "");
@@ -13,6 +14,43 @@ const MAX_FILE_BYTES = 96 * 1024;
13
14
  const MAX_TREE_ENTRIES = 2000;
14
15
  /** Bound each Git-host call so a slow/hung host can't pin a review lens to its whole deadline. */
15
16
  const REQUEST_TIMEOUT_MS = 15_000;
17
+ export { GIT_API_KINDS, isGitApiKind } from "../git-api-kind.js";
18
+ /** Wire schemas for the Git-host responses we consume (both dialects share these shapes — Gitea copied
19
+ * GitHub's). Boundary discipline(宪法 [2704] §2):no bare `as` on `.json()`. */
20
+ const RepoInfoWire = z.looseObject({ default_branch: z.string().optional() });
21
+ const TreeWire = z.looseObject({
22
+ tree: z.array(z.looseObject({ path: z.string(), type: z.string() })).optional(),
23
+ truncated: z.boolean().optional(),
24
+ });
25
+ const ContentsWire = z.looseObject({
26
+ content: z.string().optional().nullable(),
27
+ encoding: z.string().optional(),
28
+ type: z.string().optional(),
29
+ });
30
+ const PullMetaWire = z.looseObject({ changed_files: z.number().optional() });
31
+ /** Shared guard: `encodeURIComponent` does NOT escape "." — a `..` segment survives the per-segment
32
+ * encode and the WHATWG URL parser collapses it when `fetch` builds the request, letting a crafted
33
+ * `path` climb out of `/repos/<owner>/<repo>/contents/` to any endpoint the read token can reach.
34
+ * The tools are contractually bound to exactly one repo, so reject traversal before it hits fetch. */
35
+ function pathSegmentsRejectingTraversal(path) {
36
+ const segments = path.split("/").filter(Boolean);
37
+ if (segments.some((s) => s === "..")) {
38
+ throw new Error(`invalid path "${path}" (".." segments are not allowed)`);
39
+ }
40
+ return segments;
41
+ }
42
+ export function createRepoClient(kind, baseUrl, token, fetchImpl = fetch) {
43
+ switch (kind) {
44
+ case "gitea":
45
+ return new GiteaClient(baseUrl, token, fetchImpl);
46
+ case "github":
47
+ return new GitHubClient(baseUrl, token, fetchImpl);
48
+ default: {
49
+ const never = kind;
50
+ throw new Error(`unknown gitApiKind ${String(never)}`);
51
+ }
52
+ }
53
+ }
16
54
  /** Thin read-only Gitea API client. Built once at startup; bound to a repo per task. */
17
55
  export class GiteaClient {
18
56
  baseUrl;
@@ -36,12 +74,12 @@ export class GiteaClient {
36
74
  return res;
37
75
  }
38
76
  async defaultBranch({ owner, repo }) {
39
- const info = (await (await this.api(`/repos/${owner}/${repo}`)).json());
77
+ const info = RepoInfoWire.parse(await (await this.api(`/repos/${owner}/${repo}`)).json());
40
78
  return info.default_branch || "main";
41
79
  }
42
80
  async tree(coords, ref) {
43
81
  const { owner, repo } = coords;
44
- const body = (await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=${MAX_TREE_ENTRIES}`)).json());
82
+ const body = TreeWire.parse(await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=${MAX_TREE_ENTRIES}`)).json());
45
83
  const paths = (body.tree ?? []).filter((e) => e.type === "blob").map((e) => e.path);
46
84
  // The upstream API caps the listing at `per_page` and reports the overflow via `truncated` — it does
47
85
  // not report how many entries were left out. Surface that (same disclosure-marker family as
@@ -52,15 +90,8 @@ export class GiteaClient {
52
90
  }
53
91
  async readFile(coords, path, ref) {
54
92
  const { owner, repo } = coords;
55
- // `encodeURIComponent` does NOT escape "." — so a `..` segment survives the per-segment encode and the
56
- // WHATWG URL parser collapses it when `fetch` builds the request, letting a crafted `path` climb out of
57
- // `/repos/<owner>/<repo>/contents/` to any endpoint the read token can reach (cross-repo / admin). The
58
- // tools are contractually bound to exactly one repo (see header), so reject traversal before it hits fetch.
59
- const segments = path.split("/").filter(Boolean);
60
- if (segments.some((s) => s === "..")) {
61
- throw new Error(`invalid path "${path}" (".." segments are not allowed)`);
62
- }
63
- const body = (await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
93
+ const segments = pathSegmentsRejectingTraversal(path);
94
+ const body = ContentsWire.parse(await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
64
95
  if (body.type !== "file" || body.content == null)
65
96
  throw new Error(`not a file: ${path}`);
66
97
  const text = body.encoding === "base64" ? Buffer.from(body.content, "base64").toString("utf8") : body.content;
@@ -75,6 +106,89 @@ export class GiteaClient {
75
106
  return text.length > MAX_FILE_BYTES * 4 ? text.slice(0, MAX_FILE_BYTES * 4) + "\n…[diff truncated]" : text;
76
107
  }
77
108
  }
109
+ /**
110
+ * Thin read-only GitHub (github.com / GHE) API client — same four-call surface as `GiteaClient`.
111
+ *
112
+ * Dialect differences (each one is why `GiteaClient` alone 404s against GitHub):
113
+ * - base URL is the API root itself (`https://api.github.com`, GHE `https://ghe.example/api/v3`) —
114
+ * no `/api/v1` prefix is appended;
115
+ * - auth is `Authorization: Bearer <token>` (Gitea uses `token <token>`);
116
+ * - a PR's unified diff comes from `/pulls/{n}` via `Accept: application/vnd.github.v3.diff`
117
+ * (Gitea uses a `.diff` URL suffix);
118
+ * - the tree API has no `per_page` cap — GitHub returns up to its own limit and reports overflow via
119
+ * `truncated`; we additionally cap client-side at MAX_TREE_ENTRIES so the model-facing bound is the
120
+ * same for both dialects.
121
+ */
122
+ export class GitHubClient {
123
+ baseUrl;
124
+ token;
125
+ fetchImpl;
126
+ constructor(baseUrl, token, fetchImpl = fetch) {
127
+ this.baseUrl = baseUrl;
128
+ this.token = token;
129
+ this.fetchImpl = fetchImpl;
130
+ }
131
+ async api(path, accept = "application/vnd.github+json") {
132
+ const headers = { accept, "x-github-api-version": "2022-11-28" };
133
+ if (this.token)
134
+ headers.authorization = `Bearer ${this.token}`;
135
+ const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}${path}`, {
136
+ headers,
137
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
138
+ });
139
+ if (!res.ok)
140
+ throw new Error(`git api ${path} → HTTP ${res.status}`);
141
+ return res;
142
+ }
143
+ async defaultBranch({ owner, repo }) {
144
+ const info = RepoInfoWire.parse(await (await this.api(`/repos/${owner}/${repo}`)).json());
145
+ return info.default_branch || "main";
146
+ }
147
+ async tree(coords, ref) {
148
+ const { owner, repo } = coords;
149
+ const body = TreeWire.parse(await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`)).json());
150
+ const paths = (body.tree ?? []).filter((e) => e.type === "blob").map((e) => e.path);
151
+ // Same disclosure-marker family as GiteaClient.tree: a capped listing must never read as the whole
152
+ // repo. Two cap sources here — upstream `truncated`, and our own client-side MAX_TREE_ENTRIES bound.
153
+ if (paths.length > MAX_TREE_ENTRIES) {
154
+ return [...paths.slice(0, MAX_TREE_ENTRIES), `…[truncated, tree has more than ${MAX_TREE_ENTRIES} entries]`];
155
+ }
156
+ return body.truncated
157
+ ? [...paths, `…[truncated, tree has more than ${MAX_TREE_ENTRIES} entries]`]
158
+ : paths;
159
+ }
160
+ async readFile(coords, path, ref) {
161
+ const { owner, repo } = coords;
162
+ const segments = pathSegmentsRejectingTraversal(path);
163
+ const body = ContentsWire.parse(await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
164
+ if (body.type !== "file" || body.content == null)
165
+ throw new Error(`not a file: ${path}`);
166
+ // GitHub 文档行为(codex F1 定谳):1–100 MB 文件走对象形返回 `content:"", encoding:"none"` ——
167
+ // content 非 null 过上面的门,非 base64 臂又会把空串当正文交出 = 「大文件被当空文件评审」的静默
168
+ // 完整性失败。显式臂响亮拒(本工具本地帽 96 KiB,>1 MB 内容也装不下,拒绝即诚实)。
169
+ if (body.encoding === "none") {
170
+ throw new Error(`file too large for the contents API: ${path} (GitHub returns encoding:"none" for files over 1 MB — this read tool cannot fetch it; review it via the PR diff or skip it)`);
171
+ }
172
+ const text = body.encoding === "base64" ? Buffer.from(body.content, "base64").toString("utf8") : body.content;
173
+ return text.length > MAX_FILE_BYTES
174
+ ? text.slice(0, MAX_FILE_BYTES) + `\n…[truncated, ${text.length - MAX_FILE_BYTES} more bytes]`
175
+ : text;
176
+ }
177
+ async pullDiff(coords, index) {
178
+ const { owner, repo } = coords;
179
+ // GitHub 在服务端静默截断 PR diff(300 文件 / 1 MB / 20k 行,文档行为;codex F2 定谳)——被截的
180
+ // 响应可能远小于本地帽,靠字节数判不出来。先取 PR 元数据拿 changed_files,与 diff 里实际出现的
181
+ // 文件数对账,少了就显式披露(上游截断与本地帽是两种截断,分开标)。
182
+ const meta = PullMetaWire.parse(await (await this.api(`/repos/${owner}/${repo}/pulls/${index}`)).json());
183
+ const res = await this.api(`/repos/${owner}/${repo}/pulls/${index}`, "application/vnd.github.v3.diff");
184
+ let text = await res.text();
185
+ const filesInDiff = (text.match(/^diff --git /gm) ?? []).length;
186
+ if (typeof meta.changed_files === "number" && filesInDiff < meta.changed_files) {
187
+ text += `\n…[upstream truncated: this diff covers ${filesInDiff} of ${meta.changed_files} changed files — GitHub caps PR diffs (300 files / 1 MB); review the remaining files individually]`;
188
+ }
189
+ return text.length > MAX_FILE_BYTES * 4 ? text.slice(0, MAX_FILE_BYTES * 4) + "\n…[diff truncated]" : text;
190
+ }
191
+ }
78
192
  /** Build the read-only repo tool set bound to one repo (cheap per-task closure; the client is shared). */
79
193
  export function repoToolsFor(client, coords) {
80
194
  const ref = (r) => (r ? Promise.resolve(r) : client.defaultBranch(coords));
@@ -1,7 +1,7 @@
1
1
  import { type WebFetchConfig, type BackgroundAgentStore, type CheckpointStore, type PromptProvider, type Runner, type SkillSpec, type SubagentSpawnContext, type ToolSpec, type WebSearchConfig } from "@sema-agent/core";
2
2
  import type { Metrics } from "../observability/metrics.js";
3
3
  import type { Logger } from "../observability/logger.js";
4
- import { GiteaClient } from "./repo-tools.js";
4
+ import { type RepoReadClient } from "./repo-tools.js";
5
5
  import { type LoadedSkill } from "./skills.js";
6
6
  import { type ScenarioHands } from "./hands-lane.js";
7
7
  /**
@@ -46,7 +46,7 @@ export interface ScenarioDeps {
46
46
  handslessSubRunner: Runner;
47
47
  model: string;
48
48
  skills: LoadedSkill[];
49
- repoClient?: GiteaClient;
49
+ repoClient?: RepoReadClient;
50
50
  /** Multi-tenant deployment (REQUIRE_PRINCIPAL). Gates the full-body `WebFetch` tool out of the
51
51
  * default roster — core says a multi-tenant deployment MUST allowlist hosts (an injected/malicious tenant model
52
52
  * could exfil via an arbitrary public URL; the SSRF floor only blocks internal). Single-user TOC keeps WebFetch. */
@@ -1,7 +1,7 @@
1
1
  import { CODE_AGENT_PROMPT, CODE_SYSTEM_PROMPT, DEFAULT_SYSTEM_PROMPT, assembleCodeTools, defaultTaskRegistry } from "@sema-agent/core";
2
2
  import { HttpError } from "../security.js";
3
3
  import { nowTool } from "./builtin-tools.js";
4
- import { GiteaClient, parseRepo, repoToolsFor } from "./repo-tools.js";
4
+ import { parseRepo, repoToolsFor } from "./repo-tools.js";
5
5
  import { createCouncilTool, clampRounds } from "./code-review-council.js";
6
6
  import { stablePrompt } from "./prompt.js";
7
7
  import { createTeamTool, teamCoordinatorPrompt, getTeam, teamNames } from "./team.js";
@@ -12,6 +12,7 @@ import type { ElicitationThrottle } from "./elicitation.js";
12
12
  import type { QuestionThrottle } from "./question.js";
13
13
  import type { InfraCostRates } from "./observability/cost-taxonomy.js";
14
14
  import type { Autonomy, CommandRule } from "./runtime-governance.js";
15
+ import type { GitApiKind } from "./git-api-kind.js";
15
16
  /** A sema-registry MCP server resolved to a core spec (env-NAME refs already → real values) plus the
16
17
  * scenarios it applies to (empty = all). `resolveSpec` filters by scenario and passes `spec` to core. */
17
18
  export interface ScopedMcpServer {
@@ -966,6 +967,10 @@ export interface ServiceConfigFlat {
966
967
  gitApiBaseUrl?: string;
967
968
  /** Read-only Git host token (server-side only; never reaches the model). */
968
969
  gitApiToken?: string;
970
+ /** Git host API dialect for the repo tools (env GIT_API_KIND, closed set gitea|github; default gitea).
971
+ * "github" = api.github.com or GHE `/api/v3` root — the Gitea client's `/api/v1` + `.diff` shapes
972
+ * 404 there (P1-2: our own repos live on GitHub and were unreviewable). Unknown word = refuse boot. */
973
+ gitApiKind: GitApiKind;
969
974
  /** S3-TOB 设计 §1.3:memory 持久面方言(env MEMORY_ENGINE_BACKEND,缺省 file)。boot-only。 */
970
975
  memoryEngineBackend: "file" | "pg" | "tidb";
971
976
  /** CC `workflowSizeGuideline` (advisory, injected into the Workflow tool card via
@@ -1017,7 +1022,7 @@ export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachme
1017
1022
  /** 组:observability(可观测)。 */
1018
1023
  export type ServiceObservabilityConfig = Pick<ServiceConfigFlat, "metricsToken" | "traceToken" | "toolTrace" | "traceThinking" | "logLevel" | "otel">;
1019
1024
  /** 组:integrations(外部集成)。`mcpServers` 由 sema-registry 适配器填(无 env 腿),归本组。 */
1020
- export type ServiceIntegrationsConfig = Pick<ServiceConfigFlat, "pluginsAllowHosts" | "mcpServers" | "configBootFetchBudgetMs" | "gitApiBaseUrl" | "gitApiToken" | "defaultScenario" | "skillsDir" | "configCenter" | "configProvider" | "configLocalDir">;
1025
+ export type ServiceIntegrationsConfig = Pick<ServiceConfigFlat, "pluginsAllowHosts" | "mcpServers" | "configBootFetchBudgetMs" | "gitApiBaseUrl" | "gitApiToken" | "gitApiKind" | "defaultScenario" | "skillsDir" | "configCenter" | "configProvider" | "configLocalDir">;
1021
1026
  /** 九个组槽。每组恒在场(`loadConfig` / `attachConfigGroups` 装好才交出配置),故不可选——新代码写
1022
1027
  * `config.modelPlane.model` 不需要 `?.`(可选组会把 `Model` 污染成 `Model | undefined`)。 */
1023
1028
  export interface ServiceConfigGroups {
package/dist/config.js CHANGED
@@ -7,6 +7,7 @@ import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js"; // d
7
7
  import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
8
8
  import { isV2ScopeKey } from "./memory-scope.js"; // A-002.4: v2 scope 前缀词表的单一属主(纯谓词,无反向依赖)
9
9
  import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
10
+ import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js"; // P1-2: 方言词表叶模块(config 不得值引 capabilities 层)
10
11
  function csv(name) {
11
12
  return (process.env[name] ?? "")
12
13
  .split(",")
@@ -1501,6 +1502,42 @@ function parseObservabilityDomain() {
1501
1502
  : undefined,
1502
1503
  };
1503
1504
  }
1505
+ /** GIT_API_KIND:repo 工具的 Git-host 方言(闭集 gitea|github,缺省 gitea)。
1506
+ * 未知词=拒启(安全轴词表纪律);另一坏形也拒启:kind 缺省/gitea 而 BASEURL 指向 github.com——
1507
+ * Gitea 形状(`/api/v1` 前缀、`.diff` 后缀)对 GitHub 恒 404,与其在任务运行时逐调用 loud 404,
1508
+ * 不如启动即指路(#123 FLEET_ADVERTISE_ADDRESS 坏形响亮拒同族)。 */
1509
+ function parseGitApiKind(raw, baseUrl) {
1510
+ const kind = raw ?? "gitea";
1511
+ if (!isGitApiKind(kind)) {
1512
+ throw new Error(`GIT_API_KIND="${raw}" is not a known Git host kind (expected one of: ${GIT_API_KINDS.join(", ")})`);
1513
+ }
1514
+ if (!baseUrl)
1515
+ return kind;
1516
+ // 配了就必须是合法 URL(USAGE 本就要求带 scheme 的完整 baseURL;坏 URL 此前静默过门、逐调用 fetch
1517
+ // 才炸 —— 启动即拒;codex F3)。尾点主机(api.github.com.)DNS 等价,归一后再比对,防绕过。
1518
+ if (!URL.canParse(baseUrl)) {
1519
+ throw new Error(`GIT_API_BASEURL="${baseUrl}" is not a valid URL (expected e.g. https://git.example.com)`);
1520
+ }
1521
+ const u = new URL(baseUrl);
1522
+ const host = u.hostname.replace(/\.$/, "").toLowerCase();
1523
+ const githubFamily = host === "github.com" || host === "api.github.com" || host.endsWith(".github.com");
1524
+ if (kind === "gitea" && githubFamily) {
1525
+ throw new Error(`GIT_API_BASEURL points at ${host} but GIT_API_KIND is "gitea" — the Gitea API shapes 404 on GitHub. Set GIT_API_KIND=github (base url should be https://api.github.com, or your GHE /api/v3 root).`);
1526
+ }
1527
+ if (kind === "github") {
1528
+ // 对称校验(codex F3):web 域名/带 path 的 api 根都会让每个仓操作 404,而客户端照建、场景照宣可用。
1529
+ if (githubFamily && host !== "api.github.com") {
1530
+ throw new Error(`GIT_API_BASEURL host ${host} is the GitHub WEB host — the API root is https://api.github.com (a repo page URL is not an API base).`);
1531
+ }
1532
+ if (host === "api.github.com" && u.pathname !== "/" && u.pathname !== "") {
1533
+ throw new Error(`GIT_API_BASEURL for github.com must be exactly https://api.github.com (no path; got path "${u.pathname}"). GHE uses https://<ghe-host>/api/v3.`);
1534
+ }
1535
+ if (u.search || u.hash) {
1536
+ throw new Error(`GIT_API_BASEURL must not carry a query or fragment (got "${baseUrl}")`);
1537
+ }
1538
+ }
1539
+ return kind;
1540
+ }
1504
1541
  /** 域:misc-integration(外部集成)—— plugins clone 白名单、git/OA 面、场景与 skill 目录、sema-registry 接入。 */
1505
1542
  function parseIntegrationsDomain() {
1506
1543
  return {
@@ -1509,6 +1546,7 @@ function parseIntegrationsDomain() {
1509
1546
  configBootFetchBudgetMs: Math.max(0, numEnv("CONFIG_BOOT_FETCH_BUDGET_MS", "1500")), // boot 首拉抢答窗;过窗转后台补齐(clay 2026-07-17:本地 5s 启动=黑洞中心同步等待)
1510
1547
  gitApiBaseUrl: process.env.GIT_API_BASEURL,
1511
1548
  gitApiToken: process.env.GIT_API_TOKEN,
1549
+ gitApiKind: parseGitApiKind(process.env.GIT_API_KIND, process.env.GIT_API_BASEURL),
1512
1550
  // BEHAVIOR CHANGE([891] clay 硬裁定):出厂缺省场景 default→code(CC 编码 persona 蒸馏版)——
1513
1551
  // 编码产品出厂态对标 CC 出厂态,通用域中性 persona 用 DEFAULT_SCENARIO=default 显式选回。
1514
1552
  defaultScenario: env("DEFAULT_SCENARIO", "code"),
@@ -1570,7 +1608,7 @@ const LIMITS_HTTP_GROUP_KEYS = [
1570
1608
  ];
1571
1609
  const OBSERVABILITY_GROUP_KEYS = ["metricsToken", "traceToken", "toolTrace", "traceThinking", "logLevel", "otel"];
1572
1610
  const INTEGRATIONS_GROUP_KEYS = [
1573
- "pluginsAllowHosts", "mcpServers", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "defaultScenario",
1611
+ "pluginsAllowHosts", "mcpServers", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "gitApiKind", "defaultScenario",
1574
1612
  "skillsDir", "configCenter", "configProvider", "configLocalDir",
1575
1613
  ];
1576
1614
  /** 组名 → 该组取景的平铺键(introspection 面:测试用它钉「每个平铺键恰好被一组取景」)。 */
@@ -43,7 +43,7 @@ export declare const FLEET_SNAPSHOT_TERMINAL_MAX_ROWS = 20;
43
43
  * 每个终态**扫描面**的行数(店侧下推的 limit)。它比 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS} 大是有
44
44
  * 具体病灶的(codex R1-M3,红先复现):店侧分页按 `createdAt DESC` 排(core 契约 + 两个 SQL 实现皆然),
45
45
  * 而本窗的判据是**结束时刻**——一条跑了三天、刚刚才结束的 workflow(正是用户此刻要看的那条)会被 20 条
46
- * 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at DESC`
46
+ * 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at_ms DESC`
47
47
  * 的窄查询(要过真双库门,已作移交项记在发车说明里)。
48
48
  *
49
49
  * 残余(如实记档):同 scope 同一终态下,若有超过本值条**更晚创建**的行,那条"早创建、刚结束"的行仍会
@@ -44,7 +44,7 @@ export const FLEET_SNAPSHOT_TERMINAL_MAX_ROWS = 20;
44
44
  * 每个终态**扫描面**的行数(店侧下推的 limit)。它比 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS} 大是有
45
45
  * 具体病灶的(codex R1-M3,红先复现):店侧分页按 `createdAt DESC` 排(core 契约 + 两个 SQL 实现皆然),
46
46
  * 而本窗的判据是**结束时刻**——一条跑了三天、刚刚才结束的 workflow(正是用户此刻要看的那条)会被 20 条
47
- * 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at DESC`
47
+ * 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at_ms DESC`
48
48
  * 的窄查询(要过真双库门,已作移交项记在发车说明里)。
49
49
  *
50
50
  * 残余(如实记档):同 scope 同一终态下,若有超过本值条**更晚创建**的行,那条"早创建、刚结束"的行仍会
@@ -0,0 +1,7 @@
1
+ /** Git-host API dialect word table (P1-2). Leaf module — the single owner shared by the config layer
2
+ * (env parse/validation) and `capabilities/repo-tools` (the two client implementations); config must not
3
+ * value-import the capabilities layer (module-cycle gate §G), so the table lives below both. */
4
+ export declare const GIT_API_KINDS: readonly ["gitea", "github"];
5
+ export type GitApiKind = (typeof GIT_API_KINDS)[number];
6
+ export declare function isGitApiKind(v: unknown): v is GitApiKind;
7
+ //# sourceMappingURL=git-api-kind.d.ts.map
@@ -0,0 +1,8 @@
1
+ /** Git-host API dialect word table (P1-2). Leaf module — the single owner shared by the config layer
2
+ * (env parse/validation) and `capabilities/repo-tools` (the two client implementations); config must not
3
+ * value-import the capabilities layer (module-cycle gate §G), so the table lives below both. */
4
+ export const GIT_API_KINDS = ["gitea", "github"];
5
+ export function isGitApiKind(v) {
6
+ return typeof v === "string" && GIT_API_KINDS.includes(v);
7
+ }
8
+ //# sourceMappingURL=git-api-kind.js.map
@@ -178,9 +178,9 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
178
178
  };
179
179
  }))
180
180
  // severity DESC, then **explicitly** oldest-first within a tier ([2027] 第五节欠单②,红先绿后:
181
- // test/assistant-triage-ordering.test.ts)。旧姿势只比 severity,靠「listByScope 是 created_at ASC
181
+ // test/assistant-triage-ordering.test.ts)。旧姿势只比 severity,靠「listByScope 是 created_at_ms ASC
182
182
  // + Array#sort 稳定」来兑现 §2 那句 oldest-first —— 而那个前提**只在本仓的 SQL 后端成立**
183
- // (checkpoint-store-sql.ts 的 `ORDER BY created_at ASC`):core 的 `CheckpointStore.listByScope` 接口
183
+ // (checkpoint-store-sql.ts 的 `ORDER BY created_at_ms ASC`):core 的 `CheckpointStore.listByScope` 接口
184
184
  // 零顺序声明,InMemory/File 两个实现直接遍历 Map(LOCAL 车道包的正是 File store)⇒ 那句承诺过去是
185
185
  // **后端相关**的。比较器自带兜底后它与 store 顺序无关。形与 listPending 的同族 sort 逐字一致
186
186
  // (checkpoint-store-sql.ts `|| a.createdAt - b.createdAt`)。createdAt 缺席(pre-1.116 投影的老行)
@@ -1406,8 +1406,8 @@ export function createHttpServer(rawDeps) {
1406
1406
  // escaping HttpError here was (a) attributed to the OPERATOR's /decide request as a bare 400/403 — undiagnosable
1407
1407
  // from that contract — and (b) swallowed by the D-D SLA deny-sweep's catch, silently failing EVERY tick. Fold it
1408
1408
  // into a typed 409 result instead: the operator sees "this parked task is blocked by a policy change" (retry
1409
- // after the policy is restored, or let terminal_at abort it); the sweep skips the row this tick (retried next
1410
- // tick, terminal_at backstop — its documented per-call-failure semantics).
1409
+ // after the policy is restored, or let terminal_at_ms abort it); the sweep skips the row this tick (retried next
1410
+ // tick, terminal_at_ms backstop — its documented per-call-failure semantics).
1411
1411
  let spec;
1412
1412
  try {
1413
1413
  spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
@@ -2651,7 +2651,7 @@ export function createHttpServer(rawDeps) {
2651
2651
  * (graceful; vs the abort the reaper's expire() gives resource_limit/needs_review). Reuses resumeCheckpoint,
2652
2652
  * so the markResuming CAS makes it idempotent across replicas (one replica wins each resume) and the parked
2653
2653
  * run row is driven correctly. A per-call failure (e.g. a redeployed scenario) is swallowed → the row stays
2654
- * pending and is retried next tick, with the terminal_at backstop as the eventual abort if the deny never
2654
+ * pending and is retried next tick, with the terminal_at_ms backstop as the eventual abort if the deny never
2655
2655
  * succeeds. Bounded per tick by the store query's LIMIT. Wired into main.ts's reaper.
2656
2656
  */
2657
2657
  async function denyExpiredApprovals(now) {
@@ -2659,7 +2659,7 @@ export function createHttpServer(rawDeps) {
2659
2659
  if (!cs)
2660
2660
  return;
2661
2661
  const expired = await cs.listExpiredApprovalGates(now).catch(() => []);
2662
- // [1591] 候裁③ 静默臂:parked 后台子代的过期 cp 不走 sweep 的重活赎回(deadline→terminal_at 窗内
2662
+ // [1591] 候裁③ 静默臂:parked 后台子代的过期 cp 不走 sweep 的重活赎回(deadline→terminal_at_ms 窗内
2663
2663
  // 每行每 tick 一条 warn 的噪声源)——其收割属 reaper 的 expire+reconcileParkedAgents 车道。判别在
2664
2664
  // resume 尝试之前做(省掉注定 409 的整条 ctx/resolveSpec 重建尝试;代价=每过期行两次店读,过期集
2665
2665
  // 本就被店查询 LIMIT 界住);命中聚合为单条 info 留痕。判别自身故障 ⇒ 按非 parked 处理(保留 warn,
@@ -2684,7 +2684,7 @@ export function createHttpServer(rawDeps) {
2684
2684
  for (const { sessionId } of expired) {
2685
2685
  // 对抗评审 2026-07-11: log the swallow — a per-tick failure retried forever (e.g. a policy change now folded
2686
2686
  // into a 409 by resumeCheckpoint, or a redeployed scenario) was fully silent; the row sat pending to its
2687
- // terminal_at with zero operator-visible signal. Behavior unchanged (skip + retry next tick), now diagnosable.
2687
+ // terminal_at_ms with zero operator-visible signal. Behavior unchanged (skip + retry next tick), now diagnosable.
2688
2688
  if (await isParkedOwned(sessionId)) {
2689
2689
  parkedSkipped += 1;
2690
2690
  continue;
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { buildSessionAudit, type SessionAudit } from "./audit.js";
12
12
  export { createLogger, type Logger, type LogLevel } from "./observability/logger.js";
13
13
  export { Metrics, createMetrics, type Labels } from "./observability/metrics.js";
14
14
  export { RateLimiter, type RateDecision } from "./observability/rate-limit.js";
15
- export { GiteaClient, repoToolsFor, parseRepo, type RepoCoords } from "./capabilities/repo-tools.js";
15
+ export { GiteaClient, GitHubClient, createRepoClient, GIT_API_KINDS, isGitApiKind, repoToolsFor, parseRepo, type GitApiKind, type RepoCoords, type RepoReadClient } from "./capabilities/repo-tools.js";
16
16
  export { nowTool } from "./capabilities/builtin-tools.js";
17
17
  export { createCouncilTool } from "./capabilities/code-review-council.js";
18
18
  export { loadSkills, skillsForScenario, type LoadedSkill } from "./capabilities/skills.js";
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export { buildSessionAudit } from "./audit.js";
17
17
  export { createLogger } from "./observability/logger.js";
18
18
  export { Metrics, createMetrics } from "./observability/metrics.js";
19
19
  export { RateLimiter } from "./observability/rate-limit.js";
20
- export { GiteaClient, repoToolsFor, parseRepo } from "./capabilities/repo-tools.js";
20
+ export { GiteaClient, GitHubClient, createRepoClient, GIT_API_KINDS, isGitApiKind, repoToolsFor, parseRepo } from "./capabilities/repo-tools.js";
21
21
  export { nowTool } from "./capabilities/builtin-tools.js";
22
22
  export { createCouncilTool } from "./capabilities/code-review-council.js";
23
23
  export { loadSkills, skillsForScenario } from "./capabilities/skills.js";
package/dist/main.js CHANGED
@@ -16,7 +16,7 @@ import { webSearchConfigFromEnv, createWebSearchBackend, setWebSearchBadPayloadO
16
16
  import { createAuthorizer, encodeCheckpointScope } from "./security.js";
17
17
  import { assertGateIntentServiceable, hasOperatorGateIntent } from "./approval.js";
18
18
  import { loadSkills } from "./capabilities/skills.js";
19
- import { GiteaClient } from "./capabilities/repo-tools.js";
19
+ import { createRepoClient } from "./capabilities/repo-tools.js";
20
20
  import { buildScenarios, builtinScenarioDetails } from "./capabilities/scenarios.js";
21
21
  import { createHandsLaneRegistry, pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
22
22
  import { createLogger } from "./observability/logger.js";
@@ -570,7 +570,7 @@ async function main() {
570
570
  // 解析搬到 boot/config-center.ts(逐字)。⚠️ 位置即契约:loadSkills 之后、buildScenarios 之前 ——
571
571
  // LKG 落盘点必须晚于 skill 正文装载(F7/codex R26),plugins 让位判据要求 plugins 晚于 center 直发 skills。
572
572
  skills = await configCenter.applyCenterCapabilities(skills);
573
- const repoClient = config.gitApiBaseUrl ? new GiteaClient(config.gitApiBaseUrl, config.gitApiToken) : undefined;
573
+ const repoClient = config.gitApiBaseUrl ? createRepoClient(config.gitApiKind, config.gitApiBaseUrl, config.gitApiToken) : undefined;
574
574
  // CC-parity: deployment-injected WebSearch backend (the leg core leaves open). Absent WEB_SEARCH_PROVIDER →
575
575
  // undefined → the default scenario doesn't assemble the WebSearch tool. The API key stays in the backend closure.
576
576
  const webSearchCfg = webSearchConfigFromEnv();
@@ -4,7 +4,7 @@ import { type Checkpoint, type CheckpointGate, type CheckpointState, type Checkp
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
5
  /**
6
6
  * design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
7
- * checkpoint's lifetime, stamped at put() into `terminal_at` INDEPENDENT of the per-approval `deadline`. The
7
+ * checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
8
8
  * SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
9
9
  * with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
10
10
  * forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
@@ -13,9 +13,9 @@ import { type SqlDriver } from "./sql-driver.js";
13
13
  */
14
14
  export declare const TERMINAL_BACKSTOP_MS: number;
15
15
  /**
16
- * design/80 D-D (adversarial fix): the crash-safe `terminal_at` backstop must fall STRICTLY AFTER any SLA
17
- * `deadline`, never AT it. `terminal_at = max(createdAt+backstop, deadline)` made the two coincide whenever an
18
- * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at-branch (which
16
+ * design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
17
+ * `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
18
+ * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
19
19
  * has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
20
20
  * wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
21
21
  * at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
@@ -252,8 +252,8 @@ export declare class SqlCheckpointStore implements CheckpointStore {
252
252
  /**
253
253
  * GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
254
254
  * sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
255
- * Called with `cutoff = Date.now()` (deadline/terminal_at are ABSOLUTE epoch-ms), so it expires any pending row
256
- * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at` crash-safe backstop has passed —
255
+ * Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
256
+ * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
257
257
  * the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
258
258
  *
259
259
  * design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
@@ -262,7 +262,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
262
262
  * AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
263
263
  * incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
264
264
  * (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
265
- * stay on the expire path. The terminal_at-branch is the crash-safe backstop for ANY kind (incl. a human gate
265
+ * stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
266
266
  * whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
267
267
  * the deadline, so it never races the deny-sweep at the deadline instant).
268
268
  */
@@ -291,7 +291,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
291
291
  * (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
292
292
  * core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
293
293
  * {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
294
- * created_at ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
294
+ * created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
295
295
  * is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
296
296
  *
297
297
  * LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
@@ -309,7 +309,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
309
309
  *
310
310
  * 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
311
311
  * 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
312
- * 谓词精确查——`(scope, session_id, tool_call_id, created_at ≥ sinceMs)` 这组条件下的行数天然极小,
312
+ * 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
313
313
  * 一次全量返回,结构上没有分页假阴性。
314
314
  *
315
315
  * 三条口径,逐条都是判据:
@@ -37,7 +37,7 @@ import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.
37
37
  const MAX_TOOL_INPUT_CHARS = 8192;
38
38
  /**
39
39
  * design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
40
- * checkpoint's lifetime, stamped at put() into `terminal_at` INDEPENDENT of the per-approval `deadline`. The
40
+ * checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
41
41
  * SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
42
42
  * with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
43
43
  * forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
@@ -46,15 +46,15 @@ const MAX_TOOL_INPUT_CHARS = 8192;
46
46
  */
47
47
  export const TERMINAL_BACKSTOP_MS = Math.max(60_000, Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS) || 30 * 86_400_000);
48
48
  /**
49
- * design/80 D-D (adversarial fix): the crash-safe `terminal_at` backstop must fall STRICTLY AFTER any SLA
50
- * `deadline`, never AT it. `terminal_at = max(createdAt+backstop, deadline)` made the two coincide whenever an
51
- * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at-branch (which
49
+ * design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
50
+ * `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
51
+ * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
52
52
  * has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
53
53
  * wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
54
54
  * at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
55
55
  * it never meaningfully delays the eventual crash-safe GC.
56
56
  */
57
- export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
57
+ export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at_ms derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
58
58
  /**
59
59
  * The capability token IS the resume credential (token-as-auth) — anyone who reads it can impersonate a
60
60
  * resume, so it must never reach the logs (which fan out to a log-aggregation pipeline). For the diagnostic
@@ -303,8 +303,8 @@ export class SqlCheckpointStore {
303
303
  // payload without an N+1 trace.turns fetch. Stored as a stringified JSON column value.
304
304
  const toolInput = boundedToolInput(pa?.args);
305
305
  try {
306
- await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at, terminal_at, gate_kind, bound_input_hash, risk_descriptor) " +
307
- "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at, terminal_at, gate_kind, bound_input_hash, risk_descriptor) " +
306
+ await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor) " +
307
+ "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor) " +
308
308
  "VALUES ($1,$2,$3,$4,'pending',$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)"), [
309
309
  token,
310
310
  cp.scope,
@@ -316,7 +316,7 @@ export class SqlCheckpointStore {
316
316
  this.json(cp, "checkpoint"),
317
317
  cp.deadline ?? null,
318
318
  cp.createdAt,
319
- Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the terminal_at-branch never races the D-D deny-sweep, and never pre-empts an operator's longer TTL
319
+ Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the terminal_at_ms-branch never races the D-D deny-sweep, and never pre-empts an operator's longer TTL
320
320
  cp.gate?.kind ?? null, // D-D SLA split: human/irreversible_ask deadline → resolve-deny; others → expire
321
321
  pa?.boundInputHash ?? null, // D-1: the opaque hash the portal must echo on /decide (surfaced via listPending so the TOCTOU binding is reachable)
322
322
  ((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate), // riskDescriptor inbox: stamp core's INERT descriptor for triage-sort
@@ -413,8 +413,8 @@ export class SqlCheckpointStore {
413
413
  const params = [this.json(outcome, "checkpoint outcome"), Date.now(), token, scope];
414
414
  if (expect)
415
415
  params.push(expect.rev);
416
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'resolved', outcome = ?, decided_at = ?, rev = rev + 1, reopen_reason = NULL WHERE token = ? AND scope = ? AND status = 'pending'" +
417
- (expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb, decided_at = $2, rev = rev + 1, reopen_reason = NULL WHERE token = $3 AND scope = $4 AND status = 'pending'" +
416
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'resolved', outcome = ?, decided_at_ms = ?, rev = rev + 1, reopen_reason = NULL WHERE token = ? AND scope = ? AND status = 'pending'" +
417
+ (expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb, decided_at_ms = $2, rev = rev + 1, reopen_reason = NULL WHERE token = $3 AND scope = $4 AND status = 'pending'" +
418
418
  (expect ? " AND rev = $5" : "")), params);
419
419
  return res.affected === 1;
420
420
  }
@@ -508,19 +508,19 @@ export class SqlCheckpointStore {
508
508
  * Uses `expired` (not a `resolve`-deny) so a CANCELLED checkpoint never pollutes resolved-count / outcome.
509
509
  */
510
510
  async expire(token, scope) {
511
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
511
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
512
512
  return res.affected === 1;
513
513
  }
514
514
  /** Interface reap: CAS-expire pending checkpoints in `scope` past `cutoff`. Returns count. */
515
515
  async reap(scope, cutoff) {
516
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
516
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
517
517
  return res.affected;
518
518
  }
519
519
  /**
520
520
  * GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
521
521
  * sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
522
- * Called with `cutoff = Date.now()` (deadline/terminal_at are ABSOLUTE epoch-ms), so it expires any pending row
523
- * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at` crash-safe backstop has passed —
522
+ * Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
523
+ * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
524
524
  * the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
525
525
  *
526
526
  * design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
@@ -529,14 +529,14 @@ export class SqlCheckpointStore {
529
529
  * AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
530
530
  * incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
531
531
  * (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
532
- * stay on the expire path. The terminal_at-branch is the crash-safe backstop for ANY kind (incl. a human gate
532
+ * stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
533
533
  * whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
534
534
  * the deadline, so it never races the deny-sweep at the deadline instant).
535
535
  */
536
536
  async reapExpired(cutoff) {
537
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? " +
538
- `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at = $1 " +
539
- `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= $3))`), [Date.now(), cutoff, cutoff]);
537
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? " +
538
+ `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 " +
539
+ `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= $3))`), [Date.now(), cutoff, cutoff]);
540
540
  return res.affected;
541
541
  }
542
542
  /**
@@ -560,11 +560,11 @@ export class SqlCheckpointStore {
560
560
  * joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
561
561
  * session claim, so the join is live for every pending row; null only in pathological windows). */
562
562
  async listPending(scope) {
563
- const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at, c.deadline, c.gate_kind, ta.task_id " +
563
+ const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
564
564
  "FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
565
565
  const { rows } = scope
566
- ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at ASC`, [scope])
567
- : await this.db.query(`${base} ORDER BY c.created_at ASC`);
566
+ ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
567
+ : await this.db.query(`${base} ORDER BY c.created_at_ms ASC`);
568
568
  const out = rows.map((r) => {
569
569
  const toolCallId = r.tool_call_id ?? null;
570
570
  const boundInputHash = r.bound_input_hash ?? null;
@@ -587,7 +587,7 @@ export class SqlCheckpointStore {
587
587
  // Both drivers return the JSON column already parsed; null for pre-migration rows. PG 库内恒干净
588
588
  // (拒绝式)——直读即审阅面=执行面。
589
589
  input: r.tool_input ?? null,
590
- createdAt: Number(r.created_at),
590
+ createdAt: Number(r.created_at_ms),
591
591
  deadline: r.deadline == null ? null : Number(r.deadline),
592
592
  riskDescriptor: parseJson(r.risk_descriptor),
593
593
  };
@@ -602,14 +602,14 @@ export class SqlCheckpointStore {
602
602
  * (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
603
603
  * core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
604
604
  * {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
605
- * created_at ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
605
+ * created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
606
606
  * is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
607
607
  *
608
608
  * LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
609
609
  * ceiling that still protects memory/latency if a scope ever accumulates pathologically many pending gates.
610
610
  */
611
611
  async listByScope(scope) {
612
- const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at ASC LIMIT 500"), [scope]);
612
+ const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
613
613
  const out = [];
614
614
  for (const r of rows) {
615
615
  // A corrupt/missing blob skips THAT row rather than crashing the whole scheduler view (review w16yqkkxv):
@@ -638,7 +638,7 @@ export class SqlCheckpointStore {
638
638
  *
639
639
  * 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
640
640
  * 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
641
- * 谓词精确查——`(scope, session_id, tool_call_id, created_at ≥ sinceMs)` 这组条件下的行数天然极小,
641
+ * 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
642
642
  * 一次全量返回,结构上没有分页假阴性。
643
643
  *
644
644
  * 三条口径,逐条都是判据:
@@ -661,9 +661,9 @@ export class SqlCheckpointStore {
661
661
  * 维,列命中行结构上也必须解 blob 才拿得到它。
662
662
  */
663
663
  async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
664
- const { rows } = await this.db.query(this.q("SELECT token, status, created_at, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
665
- "WHERE scope=? AND session_id=? AND created_at>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at ASC", "SELECT token, status, created_at, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
666
- "WHERE scope=$1 AND session_id=$2 AND created_at>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at ASC"), [scope, sessionId, sinceMs, toolCallId]);
664
+ const { rows } = await this.db.query(this.q("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
665
+ "WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
666
+ "WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
667
667
  const out = [];
668
668
  for (const r of rows) {
669
669
  // `sourceTaskId: null` 是**所有 unparseable 臂的共同底**(读不出的行不许带出一个可用于身份比对的
@@ -671,7 +671,7 @@ export class SqlCheckpointStore {
671
671
  const base = {
672
672
  token: String(r.token),
673
673
  status: String(r.status),
674
- createdAtMs: Number(r.created_at),
674
+ createdAtMs: Number(r.created_at_ms),
675
675
  boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
676
676
  sourceTaskId: null,
677
677
  };
@@ -84,7 +84,7 @@ export declare class LocalCheckpointStore {
84
84
  * design/80 D-D global expiry sweep — same kind-split as the TiDB twin: the deadline branch abort-expires
85
85
  * every kind EXCEPT a human/irreversible_ask approval gate (those get the graceful resolve-DENY via
86
86
  * listExpiredApprovalGates), with the AskUserQuestion carve-back (denying a question is incoherent → expire);
87
- * the terminal_at branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
87
+ * the terminal_at_ms branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
88
88
  */
89
89
  reapExpired(cutoff: number): Promise<number>;
90
90
  /** design/80 D-D SLA deny-sweep input — pending human/irreversible_ask gates past deadline (excl. AskUserQuestion). */
@@ -26,7 +26,7 @@ import { join } from "node:path";
26
26
  import { isApprovalGateKind } from "../tool-approval.js"; // A-002.1 单一属主
27
27
  import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
28
28
  import { boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
29
- /** design/80 D-D read-time twin of the TiDB put-time `terminal_at` column (same formula — anti-drift). */
29
+ /** design/80 D-D read-time twin of the TiDB put-time `terminal_at_ms` column (same formula — anti-drift). */
30
30
  function terminalAtOf(cp) {
31
31
  return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
32
32
  }
@@ -286,7 +286,7 @@ export class LocalCheckpointStore {
286
286
  * design/80 D-D global expiry sweep — same kind-split as the TiDB twin: the deadline branch abort-expires
287
287
  * every kind EXCEPT a human/irreversible_ask approval gate (those get the graceful resolve-DENY via
288
288
  * listExpiredApprovalGates), with the AskUserQuestion carve-back (denying a question is incoherent → expire);
289
- * the terminal_at branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
289
+ * the terminal_at_ms branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
290
290
  */
291
291
  async reapExpired(cutoff) {
292
292
  let n = 0;
@@ -44,8 +44,8 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
44
44
  const embeddingCol = vec?.pgvector ? `vector(${vec.dimensions})` : "jsonb";
45
45
  await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.entry} (
46
46
  id text COLLATE "C" PRIMARY KEY,
47
- scope text COLLATE "C" NOT NULL,
48
- slug text COLLATE "C" NOT NULL,
47
+ scope varchar(190) COLLATE "C" NOT NULL,
48
+ slug varchar(512) COLLATE "C" NOT NULL,
49
49
  frontmatter jsonb NOT NULL,
50
50
  body text COLLATE "C" NOT NULL,
51
51
  rev text COLLATE "C" NOT NULL,
@@ -66,7 +66,7 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
66
66
  // suffix. NOT wrapped in try/catch — if this constraint cannot be created, the backend is unsafe.
67
67
  await query(`CREATE UNIQUE INDEX IF NOT EXISTS uq_${PG_MEMORY_ENGINE_TABLES.entry}_scope_slug ON ${PG_MEMORY_ENGINE_TABLES.entry} (scope, slug)`);
68
68
  await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.cursor} (
69
- scope text COLLATE "C" PRIMARY KEY,
69
+ scope varchar(190) COLLATE "C" PRIMARY KEY,
70
70
  cursor text COLLATE "C" NOT NULL
71
71
  )`);
72
72
  }
@@ -96,11 +96,11 @@ export const PG_SCHEMA_STATEMENTS = [
96
96
  checkpoint JSONB NOT NULL,
97
97
  outcome JSONB,
98
98
  deadline BIGINT,
99
- created_at BIGINT NOT NULL,
100
- decided_at BIGINT,
99
+ created_at_ms BIGINT NOT NULL,
100
+ decided_at_ms BIGINT,
101
101
  rev BIGINT NOT NULL DEFAULT 0,
102
102
  reopen_reason VARCHAR(32) COLLATE "C",
103
- terminal_at BIGINT,
103
+ terminal_at_ms BIGINT,
104
104
  gate_kind VARCHAR(32) COLLATE "C",
105
105
  bound_input_hash VARCHAR(190) COLLATE "C",
106
106
  -- pending_steer / pending_steer_queue / pending_steer_rev:队列化后的 durable steering(core 5.14.0
@@ -222,7 +222,7 @@ export const PG_SCHEMA_STATEMENTS = [
222
222
  source_run_id VARCHAR(191) COLLATE "C" NOT NULL,
223
223
  scope VARCHAR(190) COLLATE "C" NOT NULL,
224
224
  new_run_id VARCHAR(191) COLLATE "C" NOT NULL,
225
- claimed_at BIGINT NOT NULL,
225
+ claimed_at_ms BIGINT NOT NULL,
226
226
  PRIMARY KEY (source_run_id, scope)
227
227
  )`,
228
228
  // P1 (fleet failover): WorkflowRunStore + completion-inbox PG twins (design doc in workflow-run-store-sql.ts).
@@ -234,7 +234,7 @@ export const PG_SCHEMA_STATEMENTS = [
234
234
  run TEXT COLLATE "C" NOT NULL,
235
235
  rev INTEGER NOT NULL DEFAULT 0,
236
236
  created_at_ms BIGINT NOT NULL,
237
- ended_at BIGINT,
237
+ ended_at_ms BIGINT,
238
238
  PRIMARY KEY (id)
239
239
  )`,
240
240
  `CREATE INDEX IF NOT EXISTS idx_wfrun_scope_created ON workflow_run (scope, created_at_ms)`,
@@ -279,8 +279,8 @@ export const PG_SCHEMA_STATEMENTS = [
279
279
  acked SMALLINT NOT NULL DEFAULT 0,
280
280
  source_task_id VARCHAR(191) COLLATE "C",
281
281
  principal VARCHAR(190) COLLATE "C",
282
- created_at BIGINT NOT NULL,
283
- acked_at BIGINT,
282
+ created_at_ms BIGINT NOT NULL,
283
+ acked_at_ms BIGINT,
284
284
  PRIMARY KEY (run_id)
285
285
  )`,
286
286
  `CREATE INDEX IF NOT EXISTS idx_wfnotify_acked ON workflow_notify_journal (acked)`,
@@ -263,11 +263,11 @@ export declare class SqlRunStore {
263
263
  */
264
264
  reapSuspended(olderThanMs: number): Promise<number>;
265
265
  /**
266
- * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at` sweep): fail a
266
+ * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a
267
267
  * suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its
268
- * absolute `terminal_at`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
268
+ * absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
269
269
  * approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row
270
- * `terminal_at` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
270
+ * `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
271
271
  * either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two
272
272
  * windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its
273
273
  * checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted
@@ -563,11 +563,11 @@ export class SqlRunStore {
563
563
  return reaped;
564
564
  }
565
565
  /**
566
- * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at` sweep): fail a
566
+ * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a
567
567
  * suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its
568
- * absolute `terminal_at`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
568
+ * absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
569
569
  * approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row
570
- * `terminal_at` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
570
+ * `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
571
571
  * either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two
572
572
  * windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its
573
573
  * checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted
@@ -233,8 +233,8 @@ export const SCHEMA_STATEMENTS = [
233
233
  checkpoint JSON NOT NULL,
234
234
  outcome JSON NULL,
235
235
  deadline BIGINT NULL,
236
- created_at BIGINT NOT NULL,
237
- decided_at BIGINT NULL,
236
+ created_at_ms BIGINT NOT NULL,
237
+ decided_at_ms BIGINT NULL,
238
238
  -- rev (design/80 D-1): monotonic optimistic-concurrency counter bumped on every resolve/reopen, so a
239
239
  -- resolve(expect) requires the rev the resume observed to still be live → fail-closed on a concurrent
240
240
  -- resolve-reopen cycle (core → checkpoint.reopened_concurrently).
@@ -244,10 +244,10 @@ export const SCHEMA_STATEMENTS = [
244
244
  -- tool_unavailable (a fresh decision is allowed). Drives core's reopen-revote validation.
245
245
  -- NULL = NEVER REOPENED (the first resume is unconstrained).
246
246
  reopen_reason VARCHAR(32) NULL,
247
- -- terminal_at: crash-safe ABSOLUTE lifetime backstop (design/80 §3 inv#3), distinct from the per-approval
247
+ -- terminal_at_ms: crash-safe ABSOLUTE lifetime backstop (design/80 §3 inv#3), distinct from the per-approval
248
248
  -- deadline, stamped at put(). NULL on PRE-MIGRATION rows — those keep their DEADLINE-BASED expiry (the old
249
249
  -- path); the backstop only covers rows suspended after the column existed.
250
- terminal_at BIGINT NULL,
250
+ terminal_at_ms BIGINT NULL,
251
251
  -- gate_kind (design/80 D-D, SLA-timer): the CheckpointGate.kind, stamped at put(), so the SLA sweep splits by
252
252
  -- kind WITHOUT parsing the JSON blob per row — human/irreversible_ask past deadline are resolve-DENIED (the
253
253
  -- model continues with a denial), resource_limit/needs_review are abandonment-TTL → expire().
@@ -430,7 +430,7 @@ export const SCHEMA_STATEMENTS = [
430
430
  source_run_id VARCHAR(191) NOT NULL,
431
431
  scope VARCHAR(190) NOT NULL,
432
432
  new_run_id VARCHAR(191) NOT NULL,
433
- claimed_at BIGINT NOT NULL,
433
+ claimed_at_ms BIGINT NOT NULL,
434
434
  PRIMARY KEY (source_run_id, scope)
435
435
  ) COLLATE utf8mb4_bin`,
436
436
  // P1 (fleet failover, 2026-07-05): durable cross-replica WorkflowRunStore twin — one row per run, the full
@@ -443,7 +443,7 @@ export const SCHEMA_STATEMENTS = [
443
443
  run MEDIUMTEXT NOT NULL,
444
444
  rev INT NOT NULL DEFAULT 0,
445
445
  created_at_ms BIGINT NOT NULL,
446
- ended_at BIGINT NULL,
446
+ ended_at_ms BIGINT NULL,
447
447
  PRIMARY KEY (id),
448
448
  KEY idx_wfrun_scope_created (scope, created_at_ms),
449
449
  KEY idx_wfrun_scope_status (scope, status)
@@ -494,8 +494,8 @@ export const SCHEMA_STATEMENTS = [
494
494
  acked TINYINT(1) NOT NULL DEFAULT 0,
495
495
  source_task_id VARCHAR(191) NULL,
496
496
  principal VARCHAR(190) NULL,
497
- created_at BIGINT NOT NULL,
498
- acked_at BIGINT NULL,
497
+ created_at_ms BIGINT NOT NULL,
498
+ acked_at_ms BIGINT NULL,
499
499
  PRIMARY KEY (run_id),
500
500
  KEY idx_wfnotify_acked (acked)
501
501
  ) COLLATE utf8mb4_bin`,
@@ -41,7 +41,7 @@ import { type SqlDriver } from "./sql-driver.js";
41
41
  /** Dual-dialect durable WorkflowJournalStore. See the file header for the dialect-delta ledger. */
42
42
  /** RB-242 租约旋钮。TTL 只是**崩溃兜底**(engine 终态 finally 显式释放;[1981]/[1984] 两层分工:
43
43
  * engine 崩了没释放 ⇒ 陈旧 claim 可被接管)。
44
- * 🔴 缺省 1h,与 core file 参考实现同值([1984] :284)——**租约无心跳**(claimed_at 在授予时刻定格,
44
+ * 🔴 缺省 1h,与 core file 参考实现同值([1984] :284)——**租约无心跳**(claimed_at_ms 在授予时刻定格,
45
45
  * 运行期间不刷新),所以 TTL 必须盖过最长合法 run 时长:取小了(我初版 15min)会在一次 >TTL 的活跑
46
46
  * 中把 claim 判陈旧、放另一副本进来接管——恰是本缝要防的双跑。要更短的接管等待,先给 engine 半场
47
47
  * 加心跳刷新,再谈调小。 */
@@ -89,8 +89,8 @@ export declare class SqlWorkflowJournalStore implements WorkflowJournalStore {
89
89
  * 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
90
90
  * 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
91
91
  * ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
92
- * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at 刷新;
93
- * ③ TTL 崩溃兜底接管:claimed_at < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
92
+ * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
93
+ * ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
94
94
  * 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
95
95
  * ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
96
96
  resumeClaim(input: {
@@ -76,8 +76,8 @@ export class SqlWorkflowJournalStore {
76
76
  * 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
77
77
  * 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
78
78
  * ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
79
- * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at 刷新;
80
- * ③ TTL 崩溃兜底接管:claimed_at < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
79
+ * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
80
+ * ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
81
81
  * 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
82
82
  * ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
83
83
  async resumeClaim(input) {
@@ -85,20 +85,20 @@ export class SqlWorkflowJournalStore {
85
85
  const ins = await this.db.query(this.q(
86
86
  // INSERT IGNORE(非 ON DUP KEY 无操作形):TiDB 对「无变化的 DUP KEY UPDATE」affected 报 1
87
87
  // (MySQL 报 0)——真双库跑出来的方言差;IGNORE 形两家都在冲突时报 0。
88
- "INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
88
+ "INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
89
89
  if (ins.affected === 1)
90
90
  return { granted: true };
91
- const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET claimed_at = ? WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "UPDATE workflow_resume_claim SET claimed_at = $1 WHERE source_run_id = $2 AND scope = $3 AND new_run_id = $4"), [now, input.sourceRunId, input.scope, input.newRunId]);
91
+ const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "UPDATE workflow_resume_claim SET claimed_at_ms = $1 WHERE source_run_id = $2 AND scope = $3 AND new_run_id = $4"), [now, input.sourceRunId, input.scope, input.newRunId]);
92
92
  if (refresh.affected >= 1)
93
93
  return { granted: true };
94
- const takeover = await this.db.query(this.q("UPDATE workflow_resume_claim SET new_run_id = ?, claimed_at = ? WHERE source_run_id = ? AND scope = ? AND claimed_at < ?", "UPDATE workflow_resume_claim SET new_run_id = $1, claimed_at = $2 WHERE source_run_id = $3 AND scope = $4 AND claimed_at < $5"), [input.newRunId, now, input.sourceRunId, input.scope, now - this.resumeClaimTtlMs]);
94
+ const takeover = await this.db.query(this.q("UPDATE workflow_resume_claim SET new_run_id = ?, claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND claimed_at_ms < ?", "UPDATE workflow_resume_claim SET new_run_id = $1, claimed_at_ms = $2 WHERE source_run_id = $3 AND scope = $4 AND claimed_at_ms < $5"), [input.newRunId, now, input.sourceRunId, input.scope, now - this.resumeClaimTtlMs]);
95
95
  if (takeover.affected >= 1)
96
96
  return { granted: true };
97
97
  const holder = await this.db.query(this.q("SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = ? AND scope = ?", "SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = $1 AND scope = $2"), [input.sourceRunId, input.scope]);
98
98
  // 行在①-③间被释放的窄窗:holder 读空 ⇒ 如实返 denied 无 holder(engine 下一次重试会在①赢)。
99
99
  const row = holder.rows[0];
100
100
  const holderId = row?.new_run_id === undefined ? undefined : String(row.new_run_id);
101
- // ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(claimed_at 未变)会 affected=0
101
+ // ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(claimed_at_ms 未变)会 affected=0
102
102
  // 掉到这里;持有者==自己仍是 granted(幂等重入语义不依赖 affected 的方言细节)。
103
103
  if (holderId === input.newRunId)
104
104
  return { granted: true };
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * ## WorkflowRunStore twin
14
14
  * One row per run: the full `WorkflowRun` as a JSON blob + EXTRACTED columns for everything SQL needs to
15
- * index/filter (scope, status, created_at_ms, ended_at) + an authoritative `rev` column (the OCC key — the
15
+ * index/filter (scope, status, created_at_ms, ended_at_ms) + an authoritative `rev` column (the OCC key — the
16
16
  * blob's own `rev` is OVERLAID from the column on every read, so writes never have to know the bumped value
17
17
  * up front). `update` is a single-statement CAS (`WHERE id AND scope [AND rev]`, `SET rev = rev + 1`);
18
18
  * `listByScope` projects через core's SHARED `summarizeWorkflowRun` (anti-drift — identical to InMemory/File).
@@ -46,7 +46,7 @@ export class SqlWorkflowRunStore {
46
46
  async put(id, run) {
47
47
  const stored = { ...run, id, rev: run.rev ?? 0 }; // key authoritative + observed-rev base (InMemory parity)
48
48
  try {
49
- await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at) VALUES (?,?,?,?,?,?,?)", "INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [id, run.scope, run.status, JSON.stringify(stored), stored.rev, run.createdAt, run.endedAt ?? null]);
49
+ await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES (?,?,?,?,?,?,?)", "INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [id, run.scope, run.status, JSON.stringify(stored), stored.rev, run.createdAt, run.endedAt ?? null]);
50
50
  }
51
51
  catch (e) {
52
52
  // create-once error every backend throws (contract) — dup-key classification is dialect-specific:
@@ -83,7 +83,7 @@ export class SqlWorkflowRunStore {
83
83
  return false; // even the skeleton is oversize — keep the prior revision
84
84
  blob = slim;
85
85
  }
86
- const res = await this.db.query(this.q(`UPDATE workflow_run SET run = ?, rev = rev + 1, status = ?, ended_at = ? WHERE id = ? AND scope = ?${expect !== undefined ? " AND rev = ?" : ""}`, `UPDATE workflow_run SET run = $1, rev = rev + 1, status = $2, ended_at = $3 WHERE id = $4 AND scope = $5${expect !== undefined ? " AND rev = $6" : ""}`), expect !== undefined
86
+ const res = await this.db.query(this.q(`UPDATE workflow_run SET run = ?, rev = rev + 1, status = ?, ended_at_ms = ? WHERE id = ? AND scope = ?${expect !== undefined ? " AND rev = ?" : ""}`, `UPDATE workflow_run SET run = $1, rev = rev + 1, status = $2, ended_at_ms = $3 WHERE id = $4 AND scope = $5${expect !== undefined ? " AND rev = $6" : ""}`), expect !== undefined
87
87
  ? [blob, run.status, run.endedAt ?? null, id, scope, expect.rev]
88
88
  : [blob, run.status, run.endedAt ?? null, id, scope]);
89
89
  return res.affected === 1; // rev always bumps → affected is a faithful CAS verdict
@@ -156,12 +156,12 @@ export class SqlWorkflowRunStore {
156
156
  return 0; // retention is always explicit
157
157
  // Cheap columns only; terminal-set + keep-N semantics computed in JS to match InMemory EXACTLY
158
158
  // (isTerminalWorkflowStatus is core's — no status list duplicated into SQL).
159
- const { rows } = await this.db.query(this.q("SELECT id, status, created_at_ms, ended_at FROM workflow_run WHERE scope = ? ORDER BY created_at_ms DESC, id DESC", "SELECT id, status, created_at_ms, ended_at FROM workflow_run WHERE scope = $1 ORDER BY created_at_ms DESC, id DESC"), [scope]);
159
+ const { rows } = await this.db.query(this.q("SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = ? ORDER BY created_at_ms DESC, id DESC", "SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = $1 ORDER BY created_at_ms DESC, id DESC"), [scope]);
160
160
  const terminal = rows.filter((r) => isTerminalWorkflowStatus(String(r.status)));
161
161
  const doomed = [];
162
162
  for (let i = 0; i < terminal.length; i++) {
163
163
  const r = terminal[i];
164
- const anchor = r.ended_at != null ? Number(r.ended_at) : Number(r.created_at_ms);
164
+ const anchor = r.ended_at_ms != null ? Number(r.ended_at_ms) : Number(r.created_at_ms);
165
165
  const tooOld = opts.maxAgeMs !== undefined && anchor < now - opts.maxAgeMs;
166
166
  const overKeep = opts.keep !== undefined && i >= opts.keep;
167
167
  if (tooOld || overKeep)
@@ -311,31 +311,31 @@ export class SqlWorkflowNotifyJournalStore {
311
311
  async record(input) {
312
312
  // Idempotent-on-runId (a second record for the same run — incl. an acked one — is a no-op): TiDB
313
313
  // `INSERT IGNORE` vs PG `ON CONFLICT (run_id) DO NOTHING`.
314
- await this.db.query(this.q("INSERT IGNORE INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at) VALUES (?,?,0,?,?,?)", "INSERT INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at) VALUES ($1,$2,0,$3,$4,$5) ON CONFLICT (run_id) DO NOTHING"), [input.runId, input.scope, input.sourceTaskId ?? null, input.principal ?? null, input.createdAt]);
314
+ await this.db.query(this.q("INSERT IGNORE INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES (?,?,0,?,?,?)", "INSERT INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES ($1,$2,0,$3,$4,$5) ON CONFLICT (run_id) DO NOTHING"), [input.runId, input.scope, input.sourceTaskId ?? null, input.principal ?? null, input.createdAt]);
315
315
  }
316
316
  async ack(runId, ackedAt) {
317
- await this.db.query(this.q("UPDATE workflow_notify_journal SET acked = 1, acked_at = ? WHERE run_id = ? AND acked = 0", "UPDATE workflow_notify_journal SET acked = 1, acked_at = $1 WHERE run_id = $2 AND acked = 0"), [ackedAt, runId]);
317
+ await this.db.query(this.q("UPDATE workflow_notify_journal SET acked = 1, acked_at_ms = ? WHERE run_id = ? AND acked = 0", "UPDATE workflow_notify_journal SET acked = 1, acked_at_ms = $1 WHERE run_id = $2 AND acked = 0"), [ackedAt, runId]);
318
318
  }
319
319
  async listPending() {
320
- const { rows } = await this.db.query("SELECT run_id, scope, source_task_id, principal, created_at FROM workflow_notify_journal WHERE acked = 0");
320
+ const { rows } = await this.db.query("SELECT run_id, scope, source_task_id, principal, created_at_ms FROM workflow_notify_journal WHERE acked = 0");
321
321
  return rows.map((r) => ({
322
322
  runId: String(r.run_id),
323
323
  scope: String(r.scope),
324
324
  acked: false,
325
325
  ...(r.source_task_id != null ? { sourceTaskId: String(r.source_task_id) } : {}),
326
326
  ...(r.principal != null ? { principal: String(r.principal) } : {}),
327
- createdAt: Number(r.created_at),
327
+ createdAt: Number(r.created_at_ms),
328
328
  }));
329
329
  }
330
330
  /** Retention (same sweep as reapAllScopes): ACKED rows are pure history — without this the twin re-opens
331
331
  * the unbounded-growth hole the same release closed for workflow_run. Pending rows are NEVER reaped (they
332
332
  * are the recovery backlog; the orphan-grace sweep is what retires a stuck pending run). */
333
333
  async reapAcked(before) {
334
- const res = await this.db.query(this.q("DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at < ?", "DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at < $1"), [before]);
334
+ const res = await this.db.query(this.q("DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < ?", "DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < $1"), [before]);
335
335
  return res.affected;
336
336
  }
337
337
  async get(runId) {
338
- const { rows } = await this.db.query(this.q("SELECT run_id, scope, acked, source_task_id, principal, created_at, acked_at FROM workflow_notify_journal WHERE run_id = ?", "SELECT run_id, scope, acked, source_task_id, principal, created_at, acked_at FROM workflow_notify_journal WHERE run_id = $1"), [runId]);
338
+ const { rows } = await this.db.query(this.q("SELECT run_id, scope, acked, source_task_id, principal, created_at_ms, acked_at_ms FROM workflow_notify_journal WHERE run_id = ?", "SELECT run_id, scope, acked, source_task_id, principal, created_at_ms, acked_at_ms FROM workflow_notify_journal WHERE run_id = $1"), [runId]);
339
339
  const r = rows[0];
340
340
  if (!r)
341
341
  return null;
@@ -345,8 +345,8 @@ export class SqlWorkflowNotifyJournalStore {
345
345
  acked: Number(r.acked) === 1,
346
346
  ...(r.source_task_id != null ? { sourceTaskId: String(r.source_task_id) } : {}),
347
347
  ...(r.principal != null ? { principal: String(r.principal) } : {}),
348
- createdAt: Number(r.created_at),
349
- ...(r.acked_at != null ? { ackedAt: Number(r.acked_at) } : {}),
348
+ createdAt: Number(r.created_at_ms),
349
+ ...(r.acked_at_ms != null ? { ackedAt: Number(r.acked_at_ms) } : {}),
350
350
  };
351
351
  }
352
352
  }
package/dist/run-local.js CHANGED
@@ -53,7 +53,7 @@ import { applyEffective, resolveMcpServers, mcpForScenario } from "./config-cent
53
53
  import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
54
54
  import { makeLoadProjectMemory, makeProbeInstructionSources } from "./project-memory.js";
55
55
  import { loadSkills } from "./capabilities/skills.js";
56
- import { GiteaClient } from "./capabilities/repo-tools.js";
56
+ import { createRepoClient } from "./capabilities/repo-tools.js";
57
57
  import { webSearchConfigFromEnv, createWebSearchBackend } from "./plugins/web-search.js";
58
58
  import { buildScenarios, selectScenario, centerScenarios } from "./capabilities/scenarios.js";
59
59
  import { pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
@@ -539,7 +539,7 @@ export async function runLocal(argv, deps = {}) {
539
539
  const handslessSubRunner = new Runner(withoutExecutionEnv(subRunnerDeps));
540
540
  // Capability layer (loadSkills + buildScenarios + selectScenario) — assembled exactly like main.ts.
541
541
  const skills = loadSkills(config.skillsDir);
542
- const repoClient = config.gitApiBaseUrl ? new GiteaClient(config.gitApiBaseUrl, config.gitApiToken) : undefined;
542
+ const repoClient = config.gitApiBaseUrl ? createRepoClient(config.gitApiKind, config.gitApiBaseUrl, config.gitApiToken) : undefined;
543
543
  // systematic-audit: wire the env-configured WebSearch backend (WEB_SEARCH_PROVIDER) exactly like main.ts, so the
544
544
  // default scenario's WebSearch tool is assembled on the CLI path too (it was silently never built before).
545
545
  const webSearchCfg = webSearchConfigFromEnv();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.9.0",
3
+ "version": "7.10.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",