@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.3

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/src/tools.ts CHANGED
@@ -1,8 +1,29 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
- import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
3
+ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken } from "./db.js";
4
4
  import { pushEvent } from "./push.js";
5
- import { getUserNetworkRole } from "./auth.js";
5
+ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
6
+ import {
7
+ buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
8
+ validateName as validateChildName,
9
+ validateRuntime,
10
+ validateModel,
11
+ validateChannelsP1,
12
+ validateEnvRefs,
13
+ FLAG_KEYS,
14
+ validateFlagValue,
15
+ ValidationError,
16
+ } from "./create-node-validate.js";
17
+ import {
18
+ putPendingEnvBlob,
19
+ takePendingEnvBlob,
20
+ newRequestId,
21
+ finalizeCreateOnFirstRegister,
22
+ startPendingEnvGcTimer,
23
+ startSweeperTimer,
24
+ auditCreateNode,
25
+ resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
26
+ } from "./create-node.js";
6
27
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
7
28
  import {
8
29
  ALLOWED_FLAGS,
@@ -1418,7 +1439,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1418
1439
  callerNetworkId: string | null,
1419
1440
  ): { row: any | null; sec1Ok: boolean } => {
1420
1441
  const row = db.get<any>(
1421
- "SELECT node_id, alias, network_id, config_revision FROM nodes WHERE node_id = ?1",
1442
+ "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
1422
1443
  nodeId,
1423
1444
  );
1424
1445
  if (!row) return { row: null, sec1Ok: false };
@@ -1751,6 +1772,362 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1751
1772
  };
1752
1773
  },
1753
1774
  );
1775
+
1776
+ // ── RFC-026 P1 — create-node + host-daemon (3 MCP tools) ──────────
1777
+ // Background timers (GC + sweeper) are idempotent + cheap; safe to
1778
+ // call on every registerTools invocation (statless mode re-registers
1779
+ // per request). They unref themselves so don't hold the event loop.
1780
+ startPendingEnvGcTimer();
1781
+ startSweeperTimer();
1782
+
1783
+ // §4.1.4 C2 — caller daemon resolved via token-bound identity (NOT
1784
+ // alias). Thin closure over the module-level helper so callers in
1785
+ // this scope can use the captured request-level vars. The pure
1786
+ // helper lives in create-node.ts so unit tests call exactly the
1787
+ // same code path the tools do (per 通信龙 PR #299 nit 1 — no inline-
1788
+ // mirror SQL in tests).
1789
+ const resolveCallerDaemonTokenBound = () =>
1790
+ _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
1791
+
1792
+ // Helper — map a ValidationError thrown from create-node-validate
1793
+ // into the MCP-tool-call JSON reply shape.
1794
+ const validationFailReply = (e: unknown) => {
1795
+ if (e instanceof ValidationError) {
1796
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
1797
+ }
1798
+ throw e;
1799
+ };
1800
+
1801
+ // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
1802
+ // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
1803
+ // row (metadata only — no env_blob in SQL), pushEvent doorbell.
1804
+ server.tool(
1805
+ "create_node",
1806
+ "Create a node on a host-daemon and start it. Daemon forks `anet node create + start` on the target machine; child reports back to hub. RFC-026.",
1807
+ {
1808
+ daemon_node_id: z.string().min(1).max(200),
1809
+ node_spec: z.object({
1810
+ name: z.string().min(1).max(64),
1811
+ runtime: z.string().min(1).max(64),
1812
+ model: z.string().min(1).max(100),
1813
+ flags: z.record(z.unknown()).optional(),
1814
+ env_refs: z.array(z.string().max(64)).optional(),
1815
+ channels: z.array(z.unknown()).optional(),
1816
+ }),
1817
+ network_id: z.string().max(200).optional(),
1818
+ },
1819
+ async ({ daemon_node_id, node_spec, network_id: clientNetId }) => {
1820
+ const effectiveNetId = getNetworkId(clientNetId);
1821
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1822
+
1823
+ // §4.1.1 — admin+ for create_node (creating a node = pulling new
1824
+ // resource + burning API quota, one level above edit single flag)
1825
+ const callerRole = enforceUserId && effectiveNetId
1826
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
1827
+ : null;
1828
+ if (callerRole !== "admin" && callerRole !== "owner") {
1829
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_create_node", required_role: "admin", caller_role: callerRole }) }] };
1830
+ }
1831
+
1832
+ // Daemon must exist + must be in caller's network + must be
1833
+ // online with role=host_supervisor capability.
1834
+ const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
1835
+ if (!daemon) {
1836
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found", daemon_node_id }) }] };
1837
+ }
1838
+ if (!sec1Ok) {
1839
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
1840
+ }
1841
+
1842
+ // Read daemon's host_supervisor capability + allowlist from its
1843
+ // last reported config_snapshot. P1 simplification: we accept
1844
+ // (allowed_runtimes empty / allowed_secret_keys absent) as
1845
+ // "accept any in the global enum"; P2 will tighten when daemon
1846
+ // self-publishes its own allowlist via daemon_capabilities.
1847
+ let daemonAllowList = new Set<string>();
1848
+ let daemonAllowedRuntimes: string[] | null = null;
1849
+ try {
1850
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
1851
+ if (snap?.daemon_capabilities?.allowed_secret_keys) {
1852
+ daemonAllowList = new Set(snap.daemon_capabilities.allowed_secret_keys);
1853
+ }
1854
+ if (Array.isArray(snap?.daemon_capabilities?.allowed_runtimes)) {
1855
+ daemonAllowedRuntimes = snap.daemon_capabilities.allowed_runtimes;
1856
+ }
1857
+ } catch { /* permissive P1 fallback */ }
1858
+
1859
+ // §4.2.2 — structural validation (catches name/runtime/model/
1860
+ // flag injection at the hub edge). Daemon repeats this; double
1861
+ // layer per RFC §4.2.2.
1862
+ try {
1863
+ validateChildName(node_spec.name);
1864
+ validateRuntime(node_spec.runtime);
1865
+ validateModel(node_spec.model);
1866
+ validateChannelsP1((node_spec as any).channels);
1867
+ for (const [k, v] of Object.entries(node_spec.flags || {})) {
1868
+ if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
1869
+ validateFlagValue(k, v);
1870
+ }
1871
+ } catch (e) {
1872
+ return validationFailReply(e);
1873
+ }
1874
+
1875
+ // P1 daemon-side allowlist: if daemon publishes allowed_runtimes,
1876
+ // enforce at hub for fast-fail; daemon repeats.
1877
+ if (daemonAllowedRuntimes && !daemonAllowedRuntimes.includes(node_spec.runtime)) {
1878
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_not_in_local_allowlist", runtime: node_spec.runtime, allowed: daemonAllowedRuntimes }) }] };
1879
+ }
1880
+
1881
+ // §4.4.7 — env_refs strict (7-step gate) + resolve to env_blob.
1882
+ let envBlob: Record<string, string> = {};
1883
+ const envRefs = (node_spec as any).env_refs;
1884
+ try {
1885
+ envBlob = validateEnvRefs(envRefs, {
1886
+ callerNetworkId: effectiveNetId || "default",
1887
+ daemonAllowList,
1888
+ networkSecretsGet: (_net: string, _key: string) => undefined, // P1: no vault yet — see note below
1889
+ });
1890
+ } catch (e) {
1891
+ return validationFailReply(e);
1892
+ }
1893
+ // P1 NOTE — network_secrets vault is RFC §4.4 / §2.4 future
1894
+ // work. For now if dashboard sends env_refs we'll reject as
1895
+ // not-in-vault (above). When the vault lands, replace the
1896
+ // `networkSecretsGet: () => undefined` line with the real DB
1897
+ // lookup; nothing else in this tool needs to change.
1898
+
1899
+ // Single-flight per (daemon, child_name): partial unique index
1900
+ // uniq_ncr_inflight already prevents racing INSERT, but we
1901
+ // surface a friendly error rather than letting the DB constraint
1902
+ // raise.
1903
+ const existing = db.get<{ request_id: string; status: string }>(
1904
+ `SELECT request_id, status FROM node_create_requests WHERE daemon_node_id = ?1 AND child_name = ?2 AND status IN ('pending', 'delivered') ORDER BY created_at DESC LIMIT 1`,
1905
+ daemon_node_id, node_spec.name,
1906
+ );
1907
+ if (existing) {
1908
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_name_conflict", existing_request_id: existing.request_id, existing_status: existing.status }) }] };
1909
+ }
1910
+
1911
+ // §4.2.4 — daemon_max_children backpressure (best-effort: count
1912
+ // currently-active children for this daemon).
1913
+ const maxChildren = (() => {
1914
+ try {
1915
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
1916
+ const m = snap?.daemon_capabilities?.max_concurrent_children;
1917
+ return (typeof m === "number" && m > 0) ? m : 20;
1918
+ } catch { return 20; }
1919
+ })();
1920
+ const childCount = db.get<{ n: number }>(
1921
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1 AND status IN ('pending', 'delivered', 'succeeded')`,
1922
+ daemon_node_id,
1923
+ )?.n || 0;
1924
+ if (childCount >= maxChildren) {
1925
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_max_children", current: childCount, max: maxChildren }) }] };
1926
+ }
1927
+
1928
+ // §2.5 step 2 + §4.4 F1 — mint child-ntok + stash env_blob in
1929
+ // Map (NOT in DB). Token row marked role='child' + request_id
1930
+ // for sweeper traceability per §4.4.8 impl note.
1931
+ const childToken = generateNetworkToken();
1932
+ const childTokenId = generateId("tok");
1933
+ const networkIdForChild = daemon.network_id || effectiveNetId || "default";
1934
+ const requestId = newRequestId();
1935
+ // Use the dashboard caller's user_id as the token's user_id so
1936
+ // resolveToken returns sane role / network on the child's side
1937
+ // (audit trail = whoever created it).
1938
+ if (!enforceUserId) {
1939
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
1940
+ }
1941
+ db.run(
1942
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
1943
+ [childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${node_spec.name}`, "network", "child", requestId]
1944
+ );
1945
+
1946
+ putPendingEnvBlob({
1947
+ request_id: requestId,
1948
+ daemon_node_id,
1949
+ env_blob: envBlob,
1950
+ child_token: childToken,
1951
+ child_token_id: childTokenId,
1952
+ });
1953
+
1954
+ // Write metadata-only row. env_blob field deliberately ABSENT
1955
+ // from the schema (see db.ts CREATE TABLE) — F1 lock.
1956
+ const envKeys = Object.keys(envBlob);
1957
+ db.run(
1958
+ `INSERT INTO node_create_requests
1959
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
1960
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
1961
+ [
1962
+ requestId, daemon_node_id, node_spec.name, networkIdForChild,
1963
+ node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
1964
+ JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
1965
+ ],
1966
+ );
1967
+
1968
+ // SSE doorbell — daemon will pull via get_create_request.
1969
+ // Payload carries ONLY request_id (no secret); daemon current
1970
+ // SSE handler resolves the rest via MCP call.
1971
+ pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
1972
+
1973
+ // §4.5 audit — dispatch succeeded
1974
+ auditCreateNode({
1975
+ action: "create_node_dispatched",
1976
+ user_id: enforceUserId,
1977
+ network_id: networkIdForChild,
1978
+ target_id: requestId,
1979
+ detail: {
1980
+ daemon_node_id,
1981
+ child_name: node_spec.name,
1982
+ runtime: node_spec.runtime,
1983
+ model: node_spec.model,
1984
+ flag_keys: Object.keys(node_spec.flags || {}),
1985
+ env_keys: envKeys,
1986
+ },
1987
+ });
1988
+
1989
+ return {
1990
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId }) }],
1991
+ };
1992
+ },
1993
+ );
1994
+
1995
+ // §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
1996
+ // child_ntok in one shot. Map evicted on take (one-shot consume).
1997
+ //
1998
+ // §4.1.4 C2 token-bound daemon resolution (PR #299 BLOCKER #1, 通信牛):
1999
+ // We MUST resolve the caller daemon via token-bound identity, NOT
2000
+ // alias. alias is NOT a security boundary — two daemons with the same
2001
+ // alias in different networks (or attacker-named-itself-the-same)
2002
+ // would otherwise resolve to the wrong row. Same class as the prior
2003
+ // report_status cross-tenant re-home bug.
2004
+ //
2005
+ // Resolution chain: caller's ntok (callerTokenId + callerTokenIsNetwork)
2006
+ // → api_tokens row → joins to nodes via name='node:<alias>' AND
2007
+ // network_id matches → unique daemon node row scoped to caller's
2008
+ // network. If the ntok isn't bound to a node, or the joined node
2009
+ // isn't a host_supervisor, reject.
2010
+ server.tool(
2011
+ "get_create_request",
2012
+ "Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026.",
2013
+ {
2014
+ request_id: z.string().min(1).max(200),
2015
+ },
2016
+ async ({ request_id }) => {
2017
+ const callerDaemon = resolveCallerDaemonTokenBound();
2018
+ if (!callerDaemon.ok) {
2019
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2020
+ }
2021
+
2022
+ const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
2023
+ `SELECT request_id, daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
2024
+ request_id,
2025
+ );
2026
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
2027
+ // §4.1.4 — strict daemon binding by token-derived node_id.
2028
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
2029
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
2030
+ }
2031
+ // Additional network-scope guard (defense in depth: row's
2032
+ // network_id MUST equal caller's network).
2033
+ if (row.network_id !== callerDaemon.networkId) {
2034
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
2035
+ }
2036
+ if (row.status !== "pending") {
2037
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_pending", current_status: row.status }) }] };
2038
+ }
2039
+
2040
+ // Take env_blob from Map; this is the one-shot consume per F1.
2041
+ // takePendingEnvBlob ALSO checks daemon binding (belt+braces).
2042
+ const blob = takePendingEnvBlob(request_id, callerDaemon.daemonNodeId);
2043
+ if (!blob) {
2044
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
2045
+ }
2046
+ // Hydrate spec from row.
2047
+ const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
2048
+ `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
2049
+ request_id,
2050
+ );
2051
+ if (!specRow) {
2052
+ // Should never happen given the earlier row read.
2053
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_vanished" }) }] };
2054
+ }
2055
+ // Mark delivered (so sweeper distinguishes F-1 from F-2).
2056
+ db.run(
2057
+ `UPDATE node_create_requests SET status = 'delivered', delivered_at = ?1 WHERE request_id = ?2 AND status = 'pending'`,
2058
+ [Date.now(), request_id],
2059
+ );
2060
+
2061
+ return {
2062
+ content: [{ type: "text" as const, text: JSON.stringify({
2063
+ ok: true,
2064
+ request_id,
2065
+ node_spec: {
2066
+ name: specRow.child_name,
2067
+ runtime: specRow.runtime,
2068
+ model: specRow.model,
2069
+ flags: JSON.parse(specRow.flags_json),
2070
+ channels: [],
2071
+ },
2072
+ child_token: blob.child_token,
2073
+ env_blob: blob.env_blob,
2074
+ }) }],
2075
+ };
2076
+ },
2077
+ );
2078
+
2079
+ // §2.5 step 4 — daemon reports outcome. Note: 'succeeded' is set
2080
+ // automatically by hub when the child first registers (content-match
2081
+ // in upsertNodeWithSec1Guard); daemon's ack here is for explicit
2082
+ // failures (fork crashed, etc.). Daemon should still call this on
2083
+ // success too — it's a useful idempotent confirmation + lets hub
2084
+ // record fork-side info.
2085
+ server.tool(
2086
+ "ack_create_request",
2087
+ "Daemon acks a create-node request (called after fork). status='started' or 'failed'. RFC-026.",
2088
+ {
2089
+ request_id: z.string().min(1).max(200),
2090
+ status: z.enum(["started", "failed", "rejected"]),
2091
+ error: z.string().max(1000).optional(),
2092
+ child_pid: z.number().int().optional(),
2093
+ },
2094
+ async ({ request_id, status, error: ackError, child_pid: _pid }) => {
2095
+ const callerDaemon = resolveCallerDaemonTokenBound();
2096
+ if (!callerDaemon.ok) {
2097
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2098
+ }
2099
+ const row = db.get<{ daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
2100
+ `SELECT daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
2101
+ request_id,
2102
+ );
2103
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
2104
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
2105
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
2106
+ }
2107
+ if (row.network_id !== callerDaemon.networkId) {
2108
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
2109
+ }
2110
+ const ackedAt = Date.now();
2111
+ if (status === "started") {
2112
+ // Don't flip to 'succeeded' here — that happens via content-
2113
+ // match when the child actually registers. We just stamp ack.
2114
+ db.run(
2115
+ `UPDATE node_create_requests SET acked_at = ?1 WHERE request_id = ?2 AND status IN ('delivered', 'pending')`,
2116
+ [ackedAt, request_id],
2117
+ );
2118
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2119
+ }
2120
+ // failed / rejected — revoke child-ntok + mark request terminal
2121
+ if (row.child_token_id) {
2122
+ db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2123
+ }
2124
+ db.run(
2125
+ `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2126
+ [status, ackError || null, ackedAt, request_id],
2127
+ );
2128
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2129
+ },
2130
+ );
1754
2131
  }
1755
2132
 
1756
2133
  // ────────────────────────────────────────────────────────────────────
@@ -1843,6 +2220,117 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
1843
2220
  `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
1844
2221
  [JSON.stringify(input.config_snapshot), input.node_id],
1845
2222
  );
2223
+ // RFC-024 — finalize any pending/restarting update whose target
2224
+ // patch is now reflected in the snapshot. Closes the
2225
+ // restart-required-never-reaches-applied gap that 通信牛 caught:
2226
+ // the old child acks `restarting` + exits 75; the new child boots,
2227
+ // reads the new config, reports status — but never had the
2228
+ // update_id to call ack_config_update(applied) itself. Hub does
2229
+ // it on the new child's behalf by content-matching the patch
2230
+ // against the reported snapshot.
2231
+ finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
2232
+ }
2233
+ // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
2234
+ // close out any pending create_node request whose child_name matches
2235
+ // this incoming alias. Same content-match pattern as RFC-024's
2236
+ // finalizePendingMatchingUpdates; the new child doesn't have the
2237
+ // request_id, so hub does the matching on its behalf.
2238
+ try {
2239
+ finalizeCreateOnFirstRegister({
2240
+ node_id: input.node_id,
2241
+ alias: input.alias || input.node_name || "",
2242
+ network_id: input.callerNetworkId ?? null,
2243
+ });
2244
+ } catch (e: any) {
2245
+ // create-node finalize is best-effort — never block report_status
2246
+ // on it. Log + continue.
2247
+ console.warn(`[commhub] create-node finalize on report_status failed: ${e?.message || e}`);
1846
2248
  }
1847
2249
  return { result: existing ? "updated" : "inserted", node_id: input.node_id };
1848
2250
  }
2251
+
2252
+ /**
2253
+ * RFC-024 restart-finalize (Option A per 通信牛 final review).
2254
+ *
2255
+ * Called from `upsertNodeWithSec1Guard` after writing a fresh
2256
+ * `config_snapshot` for the node. For each pending/restarting update
2257
+ * row, parse the patch and compare every field against the snapshot.
2258
+ * If everything in the patch is now reflected in the live snapshot,
2259
+ * mark the update applied + bump `nodes.config_revision`. The new
2260
+ * child doesn't need to know the update_id — content-matching against
2261
+ * the live state IS the proof that the apply landed.
2262
+ *
2263
+ * Edge cases:
2264
+ * - Empty patch (apply_mode=restart_only from `restart_node` tool) →
2265
+ * ANY snapshot matches (the restart itself was the goal).
2266
+ * - Patch field absent from snapshot → not a match (snapshot
2267
+ * post-dates the apply only when every requested field shows up).
2268
+ * - Multiple concurrent pending rows → impossible by single-flight,
2269
+ * but defensive: finalize OLDEST first so the chain stays linear.
2270
+ *
2271
+ * Pure-ish: takes a snapshot blob (TS shape, see config-snapshot type)
2272
+ * and runs only db.run / db.get. No external IO.
2273
+ */
2274
+ export function finalizePendingMatchingUpdates(
2275
+ nodeId: string,
2276
+ snapshot: any,
2277
+ ): { finalizedCount: number; finalizedIds: string[] } {
2278
+ const pending = db.all<{
2279
+ update_id: string;
2280
+ patch_json: string;
2281
+ apply_mode: string;
2282
+ base_revision: number;
2283
+ }>(
2284
+ "SELECT update_id, patch_json, apply_mode, base_revision FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at ASC",
2285
+ nodeId,
2286
+ );
2287
+ if (pending.length === 0) return { finalizedCount: 0, finalizedIds: [] };
2288
+
2289
+ const snapModel: string | null | undefined = snapshot?.model;
2290
+ const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
2291
+
2292
+ const finalizedIds: string[] = [];
2293
+
2294
+ for (const row of pending) {
2295
+ let patch: any = {};
2296
+ try { patch = JSON.parse(row.patch_json); } catch { continue; }
2297
+
2298
+ let matches = true;
2299
+ // restart_only / empty patch → any snapshot proves the restart
2300
+ // happened, finalize unconditionally.
2301
+ if (row.apply_mode !== "restart_only") {
2302
+ if (patch.model !== undefined) {
2303
+ if (snapModel !== patch.model) { matches = false; }
2304
+ }
2305
+ if (matches && patch.flags && typeof patch.flags === "object") {
2306
+ for (const [k, v] of Object.entries(patch.flags as Record<string, unknown>)) {
2307
+ // Canonical-key fallback for legacy aliases. The dashboard
2308
+ // schema uses `budget` and `timeout`; node-side config may
2309
+ // also carry the older `maxBudgetUsd` / `claudeTimeoutMs` /
2310
+ // `codexTimeoutMs` keys. agent-node's buildConfigSnapshot
2311
+ // only reports the canonical key, so we just compare on `k`.
2312
+ if (snapFlags[k] !== v) { matches = false; break; }
2313
+ }
2314
+ }
2315
+ }
2316
+
2317
+ if (!matches) continue;
2318
+
2319
+ // Promote nodes.config_revision atomically with the update row.
2320
+ const nextRev = (db.get<{ config_revision: number }>(
2321
+ "SELECT config_revision FROM nodes WHERE node_id = ?1",
2322
+ nodeId,
2323
+ )?.config_revision || 0) + 1;
2324
+ db.run(
2325
+ "UPDATE node_config_updates SET status = 'applied', acked_at = ?1, new_revision = ?2 WHERE update_id = ?3 AND status IN ('pending', 'restarting')",
2326
+ [Date.now(), nextRev, row.update_id],
2327
+ );
2328
+ db.run("UPDATE nodes SET config_revision = ?1 WHERE node_id = ?2", [nextRev, nodeId]);
2329
+ finalizedIds.push(row.update_id);
2330
+ console.log(
2331
+ `[commhub] ✓ finalize update ${row.update_id} via report_status content-match: node=${nodeId} new_revision=${nextRev} apply_mode=${row.apply_mode}`,
2332
+ );
2333
+ }
2334
+
2335
+ return { finalizedCount: finalizedIds.length, finalizedIds };
2336
+ }