negotium 0.19.2 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-helpers.js +1147 -213
- package/dist/agent-helpers.js.map +28 -20
- package/dist/background-bash.js +2 -2
- package/dist/background-bash.js.map +3 -3
- package/dist/browser-runtime.js +2 -2
- package/dist/browser-runtime.js.map +3 -3
- package/dist/{chunk-aqqmzmft.js → chunk-143j4nxd.js} +1 -1
- package/dist/{chunk-55dd0xw7.js → chunk-myhmb8tn.js} +3 -3
- package/dist/{chunk-55dd0xw7.js.map → chunk-myhmb8tn.js.map} +3 -3
- package/dist/{chunk-cv7vdsrn.js → chunk-t7vs5k9t.js} +287 -10
- package/dist/{chunk-cv7vdsrn.js.map → chunk-t7vs5k9t.js.map} +11 -8
- package/dist/{chunk-exqz8bez.js → chunk-wpp1x0tx.js} +3 -3
- package/dist/{chunk-exqz8bez.js.map → chunk-wpp1x0tx.js.map} +2 -2
- package/dist/{chunk-kkftbxh2.js → chunk-zkyp090x.js} +1 -1
- package/dist/hosted-agent.js +290 -12
- package/dist/hosted-agent.js.map +10 -7
- package/dist/main.js +5019 -3134
- package/dist/main.js.map +51 -40
- package/dist/mcp-factories.js +1167 -163
- package/dist/mcp-factories.js.map +28 -20
- package/dist/media.js +2 -2
- package/dist/media.js.map +3 -3
- package/dist/prompts.js +2 -2
- package/dist/prompts.js.map +3 -3
- package/dist/query-runtime.js +2 -2
- package/dist/query-runtime.js.map +5 -5
- package/dist/registry.js +3 -3
- package/dist/rollout.js +2 -2
- package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
- package/dist/runtime/src/agents/public-helpers.ts +5 -0
- package/dist/runtime/src/agents/self-config-core.ts +28 -58
- package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
- package/dist/runtime/src/index.ts +75 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
- package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
- package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
- package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
- package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
- package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
- package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
- package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
- package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
- package/dist/runtime/src/mcp-runtime-host.ts +2 -1
- package/dist/runtime/src/node-host.ts +26 -0
- package/dist/runtime/src/platform/config.ts +1 -1
- package/dist/runtime/src/platform/mcp-config.ts +56 -2
- package/dist/runtime/src/query/active-rooms.ts +6 -0
- package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
- package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
- package/dist/runtime/src/runtime/bashrs-completions.ts +19 -1
- package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
- package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
- package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
- package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
- package/dist/runtime/src/runtime/turn-runner.ts +157 -33
- package/dist/runtime/src/runtime-gateway.ts +20 -0
- package/dist/runtime/src/storage/api-topics.ts +22 -3
- package/dist/runtime/src/storage/remote-session.ts +526 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
- package/dist/runtime/src/topics/derive.ts +3 -0
- package/dist/runtime/src/types/api.ts +6 -0
- package/dist/runtime/src/types.ts +42 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-gateway.js.map +2 -2
- package/dist/runtime-helpers.js +13 -3
- package/dist/runtime-helpers.js.map +5 -5
- package/dist/storage.js +16 -4
- package/dist/storage.js.map +4 -4
- package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
- package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
- package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
- package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
- package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
- package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
- package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
- package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
- package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
- package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
- package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
- package/dist/types/packages/core/src/platform/config.d.ts +1 -1
- package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
- package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
- package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
- package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
- package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
- package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
- package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
- package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
- package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
- package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
- package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
- package/dist/types/packages/core/src/types/api.d.ts +6 -0
- package/dist/types/packages/core/src/types.d.ts +40 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js +2 -2
- package/dist/vault.js.map +3 -3
- package/install-browser-rs.mjs +4 -4
- package/package.json +1 -1
- /package/dist/{chunk-aqqmzmft.js.map → chunk-143j4nxd.js.map} +0 -0
- /package/dist/{chunk-kkftbxh2.js.map → chunk-zkyp090x.js.map} +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getTopic,
|
|
3
|
+
listSubagentChildTopicIds,
|
|
4
|
+
listSubagentTellTargetIds,
|
|
5
|
+
} from "#storage/api-topics";
|
|
6
|
+
import type { ActorTopicScope } from "#types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rooms a turn may reach because of lineage this node itself recorded, not
|
|
10
|
+
* because a person is in them: a subagent's direct parent and the targets an
|
|
11
|
+
* ancestor granted it, and a room's own subagent workers.
|
|
12
|
+
*
|
|
13
|
+
* Lineage stands in for an assertion only when there is none — the turns that
|
|
14
|
+
* manage subagents (the spawn, the report back, the follow-up) are turns no
|
|
15
|
+
* person started, they carry no `actorTopicScope`, and cutting them off would
|
|
16
|
+
* break delegation the moment it began. A turn that *does* carry an assertion
|
|
17
|
+
* never gets lineage on top of it: the hub mirrors a subagent room with its
|
|
18
|
+
* parent's roster, so an owner of the parent is asserted as owner of the child
|
|
19
|
+
* and needs no exception, while a member who is not the owner — or a person
|
|
20
|
+
* speaking from a subagent room an ancestor granted rooms outside that
|
|
21
|
+
* person's own — must not inherit authority the hub did not assert.
|
|
22
|
+
* `canSubagentTellTarget` still applies its own rules on top (status-only,
|
|
23
|
+
* grants).
|
|
24
|
+
*/
|
|
25
|
+
export function subagentLineageTopicIds(currentTopicId: string | undefined): {
|
|
26
|
+
reachable: Set<string>;
|
|
27
|
+
children: Set<string>;
|
|
28
|
+
} {
|
|
29
|
+
const reachable = new Set<string>();
|
|
30
|
+
const children = new Set<string>();
|
|
31
|
+
if (!currentTopicId) return { reachable, children };
|
|
32
|
+
const current = getTopic(currentTopicId);
|
|
33
|
+
if (current?.isSubagent && current.parentTopicId) {
|
|
34
|
+
reachable.add(current.parentTopicId);
|
|
35
|
+
for (const id of listSubagentTellTargetIds(currentTopicId)) reachable.add(id);
|
|
36
|
+
}
|
|
37
|
+
for (const id of listSubagentChildTopicIds(currentTopicId)) {
|
|
38
|
+
reachable.add(id);
|
|
39
|
+
children.add(id);
|
|
40
|
+
}
|
|
41
|
+
return { reachable, children };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The rooms cross-room tools may name for this turn.
|
|
46
|
+
*
|
|
47
|
+
* Off the `otium` surface the node owns membership, so nothing narrows the
|
|
48
|
+
* caller's own rooms. On `otium` the hub does, and its assertion is
|
|
49
|
+
* authoritative: with one, exactly the current room plus the asserted visible
|
|
50
|
+
* rooms are reachable — no lineage. Without one (an older hub, or a turn no
|
|
51
|
+
* person started) only the current room and its subagent lineage are
|
|
52
|
+
* reachable (fail-closed).
|
|
53
|
+
*/
|
|
54
|
+
export function actorReachableTopicIds(input: {
|
|
55
|
+
surface: string | undefined;
|
|
56
|
+
currentTopicId: string | undefined;
|
|
57
|
+
actorTopicScope: ActorTopicScope | undefined;
|
|
58
|
+
}): ReadonlySet<string> | null {
|
|
59
|
+
if (input.surface !== "otium") return null;
|
|
60
|
+
const ids = new Set<string>();
|
|
61
|
+
if (input.currentTopicId) ids.add(input.currentTopicId);
|
|
62
|
+
if (input.actorTopicScope) {
|
|
63
|
+
for (const id of input.actorTopicScope.visibleNodeTopicIds) ids.add(id);
|
|
64
|
+
return ids;
|
|
65
|
+
}
|
|
66
|
+
for (const id of subagentLineageTopicIds(input.currentTopicId).reachable) ids.add(id);
|
|
67
|
+
return ids;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Rooms the actor may abort/restart/delete. With an assertion: exactly the
|
|
72
|
+
* rooms the hub says the actor owns (a parent's subagent workers are among
|
|
73
|
+
* them only if the hub asserted so). Without one: the current room's own
|
|
74
|
+
* subagent workers (a room controls what it delegated), nothing else. Same
|
|
75
|
+
* surface rule as {@link actorReachableTopicIds}.
|
|
76
|
+
*/
|
|
77
|
+
export function actorOwnedTopicIds(input: {
|
|
78
|
+
surface: string | undefined;
|
|
79
|
+
currentTopicId?: string | undefined;
|
|
80
|
+
actorTopicScope: ActorTopicScope | undefined;
|
|
81
|
+
}): ReadonlySet<string> | null {
|
|
82
|
+
if (input.surface !== "otium") return null;
|
|
83
|
+
if (input.actorTopicScope) return new Set(input.actorTopicScope.ownedNodeTopicIds);
|
|
84
|
+
return subagentLineageTopicIds(input.currentTopicId).children;
|
|
85
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ActorTopicScope } from "#types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hard caps on a hub-supplied room assertion.
|
|
5
|
+
*
|
|
6
|
+
* The assertion is not stored and forgotten: it rides the signed per-turn MCP
|
|
7
|
+
* token, which every hosted built-in MCP server receives in the URL query
|
|
8
|
+
* (`?token=`) of an `http://127.0.0.1` request, and — with
|
|
9
|
+
* `NEGOTIUM_BUILTIN_MCP_TRANSPORT=stdio` — one base64url argv entry of the
|
|
10
|
+
* session-comm child. The transport, not the database, is the tight bound:
|
|
11
|
+
*
|
|
12
|
+
* - `Bun.serve` answers 431 once the request line is too long (Bun 1.3.14:
|
|
13
|
+
* a 16,205-character URL is served, 16,305 is refused). These caps alone
|
|
14
|
+
* do not bound the URL — the token carries other fields — so the whole URL
|
|
15
|
+
* is budgeted at `MCP_URL_BUDGET_CHARS` (14 KiB) in `runtime-spec.ts`,
|
|
16
|
+
* where the numbers live. The 8 KiB assertion below becomes 10,924
|
|
17
|
+
* base64url characters at most; with every other runtime-token field at
|
|
18
|
+
* its largest (the prompt itself is not in the token — only the derived
|
|
19
|
+
* `explicitAgentSwitchTargets`, at most three agent names) the URL is
|
|
20
|
+
* ≤ ~13.4 KB.
|
|
21
|
+
* - argv: `encodeActorTopicScopeArg` is the assertion alone, ≤ 10,924
|
|
22
|
+
* characters, far below Linux `MAX_ARG_STRLEN` (128 KiB) for one argument
|
|
23
|
+
* and macOS `ARG_MAX` (1 MiB) overall. The encoder re-validates before it
|
|
24
|
+
* encodes, so an in-process caller that bypassed the gateway parser cannot
|
|
25
|
+
* put an unbounded object into argv either.
|
|
26
|
+
*
|
|
27
|
+
* With UUID room ids (39 bytes each serialized) the byte cap admits roughly
|
|
28
|
+
* 208 ids across both lists; the per-list count cap bounds the work done on
|
|
29
|
+
* an assertion before its size is known, and the id length cap keeps a single
|
|
30
|
+
* id from being the whole budget. Raising these means moving the assertion
|
|
31
|
+
* out of the token (server-side per-turn context), not editing the numbers.
|
|
32
|
+
*/
|
|
33
|
+
export const ACTOR_TOPIC_SCOPE_LIMITS = Object.freeze({
|
|
34
|
+
/** Ids per list, counted before de-duplication. */
|
|
35
|
+
maxIdsPerList: 200,
|
|
36
|
+
/** Characters per id, after trimming. */
|
|
37
|
+
maxIdLength: 128,
|
|
38
|
+
/** UTF-8 bytes of the normalized assertion as JSON. */
|
|
39
|
+
maxSerializedBytes: 8 * 1024,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const SCOPE_KEYS = ["visibleNodeTopicIds", "ownedNodeTopicIds"] as const;
|
|
43
|
+
|
|
44
|
+
export type ActorTopicScopeValidation =
|
|
45
|
+
| { ok: true; scope: ActorTopicScope | undefined }
|
|
46
|
+
| { ok: false; error: string };
|
|
47
|
+
|
|
48
|
+
function idList(value: unknown, name: string): string[] | string {
|
|
49
|
+
if (!Array.isArray(value)) return `${name} must be an array of topic ids`;
|
|
50
|
+
if (value.length > ACTOR_TOPIC_SCOPE_LIMITS.maxIdsPerList) {
|
|
51
|
+
return `${name} has ${value.length} ids; at most ${ACTOR_TOPIC_SCOPE_LIMITS.maxIdsPerList} are allowed`;
|
|
52
|
+
}
|
|
53
|
+
const ids = new Set<string>();
|
|
54
|
+
for (const item of value) {
|
|
55
|
+
if (typeof item !== "string") return `${name} must contain only strings`;
|
|
56
|
+
const id = item.trim();
|
|
57
|
+
if (!id) return `${name} must not contain empty ids`;
|
|
58
|
+
if (id.length > ACTOR_TOPIC_SCOPE_LIMITS.maxIdLength) {
|
|
59
|
+
return `${name} contains an id longer than ${ACTOR_TOPIC_SCOPE_LIMITS.maxIdLength} characters`;
|
|
60
|
+
}
|
|
61
|
+
ids.add(id);
|
|
62
|
+
}
|
|
63
|
+
return [...ids];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate a hub-supplied room assertion and say why it failed.
|
|
68
|
+
*
|
|
69
|
+
* `undefined` in means `undefined` out (no assertion, fail-closed downstream);
|
|
70
|
+
* anything else must be exactly `{ visibleNodeTopicIds, ownedNodeTopicIds }`
|
|
71
|
+
* — a plain object with those two own keys and nothing else (so a stray
|
|
72
|
+
* `__proto__` or `constructor` key is a rejection, not a property), each a
|
|
73
|
+
* list of non-empty strings within {@link ACTOR_TOPIC_SCOPE_LIMITS} — or it is
|
|
74
|
+
* rejected, so a malformed assertion can never be read as "everything".
|
|
75
|
+
*/
|
|
76
|
+
export function validateActorTopicScope(value: unknown): ActorTopicScopeValidation {
|
|
77
|
+
if (value === undefined) return { ok: true, scope: undefined };
|
|
78
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error:
|
|
82
|
+
"actorTopicScope must be { visibleNodeTopicIds: string[], ownedNodeTopicIds: string[] }",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const proto = Object.getPrototypeOf(value);
|
|
86
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
87
|
+
return { ok: false, error: "actorTopicScope must be a plain object" };
|
|
88
|
+
}
|
|
89
|
+
const keys = Object.keys(value);
|
|
90
|
+
const unknown = keys.filter((key) => !(SCOPE_KEYS as readonly string[]).includes(key));
|
|
91
|
+
if (unknown.length || keys.length !== SCOPE_KEYS.length) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: unknown.length
|
|
95
|
+
? `actorTopicScope has unexpected key(s): ${unknown.join(", ")}`
|
|
96
|
+
: "actorTopicScope must have both visibleNodeTopicIds and ownedNodeTopicIds",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const record = value as Record<string, unknown>;
|
|
100
|
+
const visible = idList(record.visibleNodeTopicIds, "actorTopicScope.visibleNodeTopicIds");
|
|
101
|
+
if (typeof visible === "string") return { ok: false, error: visible };
|
|
102
|
+
const owned = idList(record.ownedNodeTopicIds, "actorTopicScope.ownedNodeTopicIds");
|
|
103
|
+
if (typeof owned === "string") return { ok: false, error: owned };
|
|
104
|
+
const scope: ActorTopicScope = { visibleNodeTopicIds: visible, ownedNodeTopicIds: owned };
|
|
105
|
+
const bytes = Buffer.byteLength(JSON.stringify(scope), "utf-8");
|
|
106
|
+
if (bytes > ACTOR_TOPIC_SCOPE_LIMITS.maxSerializedBytes) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: `actorTopicScope is ${bytes} bytes serialized; at most ${ACTOR_TOPIC_SCOPE_LIMITS.maxSerializedBytes} are allowed`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, scope };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate a hub-supplied room assertion.
|
|
117
|
+
*
|
|
118
|
+
* `undefined` in means `undefined` out (no assertion, fail-closed downstream);
|
|
119
|
+
* anything else must pass {@link validateActorTopicScope} or it is rejected
|
|
120
|
+
* with `null`. Every path an assertion can enter by — the gateway body, the
|
|
121
|
+
* signed MCP tokens, the stdio argv — goes through this one parser, so the
|
|
122
|
+
* limits cannot drift between them.
|
|
123
|
+
*/
|
|
124
|
+
export function parseActorTopicScope(value: unknown): ActorTopicScope | undefined | null {
|
|
125
|
+
const validated = validateActorTopicScope(value);
|
|
126
|
+
return validated.ok ? validated.scope : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isActorTopicScope(value: unknown): value is ActorTopicScope {
|
|
130
|
+
return parseActorTopicScope(value) != null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read a validated assertion out of a persisted or signed context. Anything
|
|
135
|
+
* that does not validate collapses to "no assertion" rather than throwing, so
|
|
136
|
+
* a stale row cannot break a turn — it only narrows it to what a turn without
|
|
137
|
+
* an assertion reaches: the current room plus its own subagent lineage.
|
|
138
|
+
*/
|
|
139
|
+
export function actorTopicScopeFrom(value: unknown): ActorTopicScope | undefined {
|
|
140
|
+
return parseActorTopicScope(value) ?? undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The strictest assertion two requests agree on: an id is kept only when both
|
|
145
|
+
* assert it, and a request without an assertion leaves the result without one
|
|
146
|
+
* (fail-closed). Used when several pending requests of one actor fold into a
|
|
147
|
+
* single turn, so the merged turn never reaches a room one of them could not.
|
|
148
|
+
*/
|
|
149
|
+
export function intersectActorTopicScopes(
|
|
150
|
+
scopes: ReadonlyArray<ActorTopicScope | undefined>,
|
|
151
|
+
): ActorTopicScope | undefined {
|
|
152
|
+
if (scopes.length === 0 || scopes.some((scope) => scope === undefined)) return undefined;
|
|
153
|
+
const [first, ...rest] = scopes as ActorTopicScope[];
|
|
154
|
+
const keep = (key: keyof ActorTopicScope) =>
|
|
155
|
+
first[key].filter((id) => rest.every((scope) => scope[key].includes(id)));
|
|
156
|
+
return {
|
|
157
|
+
visibleNodeTopicIds: keep("visibleNodeTopicIds"),
|
|
158
|
+
ownedNodeTopicIds: keep("ownedNodeTopicIds"),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Argv-safe encoding for the stdio session-comm server (`--actor-topic-scope=`).
|
|
164
|
+
* Bounded by {@link ACTOR_TOPIC_SCOPE_LIMITS}: 8,192 bytes of JSON become at
|
|
165
|
+
* most 10,924 base64url characters, see there. The input is put through
|
|
166
|
+
* {@link validateActorTopicScope} first and an invalid or oversized assertion
|
|
167
|
+
* throws — it is never truncated (which would narrow the turn silently) nor
|
|
168
|
+
* passed on (which would let a typed in-process caller overflow argv).
|
|
169
|
+
*/
|
|
170
|
+
export function encodeActorTopicScopeArg(scope: ActorTopicScope): string {
|
|
171
|
+
const validated = validateActorTopicScope(scope);
|
|
172
|
+
if (!validated.ok) throw new Error(validated.error);
|
|
173
|
+
if (!validated.scope) throw new Error("actorTopicScope is required");
|
|
174
|
+
return Buffer.from(JSON.stringify(validated.scope), "utf-8").toString("base64url");
|
|
175
|
+
}
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* (the ask/tell REST routes stayed in the host).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
deliverPeerReply,
|
|
14
|
+
isHubRemoteReplyRoute,
|
|
15
|
+
parseHubRemoteReplyRoute,
|
|
16
|
+
type RemoteReplyRoute,
|
|
17
|
+
} from "#mcp/session-comm/peer-forward";
|
|
13
18
|
import { db } from "#storage/forum-db";
|
|
14
19
|
import { PENDING_ASK_TTL_MS, type PendingAskUserId } from "#storage/session-asks";
|
|
15
20
|
import { registerStorageSchemaInitializer } from "#storage/storage-host";
|
|
@@ -31,6 +36,13 @@ registerStorageSchemaInitializer((database) => {
|
|
|
31
36
|
created_at INTEGER NOT NULL
|
|
32
37
|
)
|
|
33
38
|
`);
|
|
39
|
+
// Hub-routed asks keep their whole route here; the legacy columns are
|
|
40
|
+
// blank for them. Added after the table existed, so guarded.
|
|
41
|
+
try {
|
|
42
|
+
database.exec("ALTER TABLE remote_ask_callbacks ADD COLUMN route_json TEXT");
|
|
43
|
+
} catch {
|
|
44
|
+
// Column already present.
|
|
45
|
+
}
|
|
34
46
|
});
|
|
35
47
|
|
|
36
48
|
interface PendingAskIdentity {
|
|
@@ -65,25 +77,29 @@ const MAX_ASK_AGE_MS = PENDING_ASK_TTL_MS;
|
|
|
65
77
|
export function registerAskCallback(entry: AskPending): void {
|
|
66
78
|
pendingAsks.set(entry.targetQueryId, entry);
|
|
67
79
|
if (entry.remoteReply) {
|
|
80
|
+
const route = entry.remoteReply;
|
|
81
|
+
const hub = isHubRemoteReplyRoute(route);
|
|
68
82
|
db.query(
|
|
69
83
|
`INSERT INTO remote_ask_callbacks
|
|
70
|
-
(target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, created_at)
|
|
71
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
84
|
+
(target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, created_at, route_json)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
72
86
|
ON CONFLICT(target_query_id) DO UPDATE SET
|
|
73
87
|
request_id = excluded.request_id,
|
|
74
88
|
node_name = excluded.node_name,
|
|
75
89
|
node_cell_id = excluded.node_cell_id,
|
|
76
90
|
topic_id = excluded.topic_id,
|
|
77
91
|
user_id = excluded.user_id,
|
|
78
|
-
created_at = excluded.created_at
|
|
92
|
+
created_at = excluded.created_at,
|
|
93
|
+
route_json = excluded.route_json`,
|
|
79
94
|
).run(
|
|
80
95
|
entry.targetQueryId,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
route.requestId,
|
|
97
|
+
route.nodeName,
|
|
98
|
+
hub ? "" : route.nodeCellId,
|
|
99
|
+
route.topicId,
|
|
100
|
+
hub ? "" : route.userId,
|
|
86
101
|
entry.createdAt,
|
|
102
|
+
hub ? JSON.stringify(route) : null,
|
|
87
103
|
);
|
|
88
104
|
}
|
|
89
105
|
}
|
|
@@ -99,10 +115,23 @@ export function resolveAskCallback(queryId: string): AskPending | null {
|
|
|
99
115
|
entry.timedOut = true;
|
|
100
116
|
}
|
|
101
117
|
pendingAsks.delete(queryId);
|
|
102
|
-
|
|
118
|
+
// A hub-routed callback keeps its durable row until the answer is in the
|
|
119
|
+
// reply outbox: `deliverHubRemoteReply` deletes it in the same transaction
|
|
120
|
+
// as the outbox insert, so a crash between resolve and enqueue still leaves
|
|
121
|
+
// the row for the startup pass to fail explicitly instead of losing both.
|
|
122
|
+
if (!entry.remoteReply || !isHubRemoteReplyRoute(entry.remoteReply)) {
|
|
123
|
+
db.query("DELETE FROM remote_ask_callbacks WHERE target_query_id = ?").run(queryId);
|
|
124
|
+
}
|
|
103
125
|
return entry;
|
|
104
126
|
}
|
|
105
127
|
|
|
128
|
+
/** The hub-routed answer is durably queued: the callback row has served its purpose. */
|
|
129
|
+
export function deleteHubRemoteAskCallbackRow(requestId: string): void {
|
|
130
|
+
db.query("DELETE FROM remote_ask_callbacks WHERE request_id = ? AND route_json IS NOT NULL").run(
|
|
131
|
+
requestId,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
106
135
|
/** Clean up stale asks that outlived their TTL (periodic, lightweight). */
|
|
107
136
|
export function purgeStaleAsks(now: number = Date.now()): void {
|
|
108
137
|
for (const [id, entry] of pendingAsks) {
|
|
@@ -130,28 +159,52 @@ interface DurableRemoteAskCallbackRow {
|
|
|
130
159
|
node_cell_id: string;
|
|
131
160
|
topic_id: string;
|
|
132
161
|
user_id: string;
|
|
162
|
+
route_json: string | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function durableRoute(row: DurableRemoteAskCallbackRow): RemoteReplyRoute | null {
|
|
166
|
+
if (row.route_json) {
|
|
167
|
+
try {
|
|
168
|
+
return parseHubRemoteReplyRoute(JSON.parse(row.route_json));
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
nodeName: row.node_name,
|
|
175
|
+
nodeCellId: row.node_cell_id,
|
|
176
|
+
topicId: row.topic_id,
|
|
177
|
+
userId: row.user_id,
|
|
178
|
+
requestId: row.request_id,
|
|
179
|
+
};
|
|
133
180
|
}
|
|
134
181
|
|
|
135
182
|
/** Fail remote asks whose target turn was interrupted by the previous process.
|
|
136
|
-
* Call once during startup after the peer session bridge is registered.
|
|
137
|
-
|
|
183
|
+
* Call once during startup after the peer session bridge is registered.
|
|
184
|
+
* `routes: "hub"` limits the pass to hub-routed asks, which need no adapter
|
|
185
|
+
* bridge and are therefore failed by the node itself at startup. */
|
|
186
|
+
export async function failInterruptedRemoteAskCallbacks(
|
|
187
|
+
options: { routes?: "all" | "hub" } = {},
|
|
188
|
+
): Promise<number> {
|
|
138
189
|
const rows = db
|
|
139
190
|
.query(
|
|
140
|
-
`SELECT target_query_id, request_id, node_name, node_cell_id, topic_id, user_id
|
|
191
|
+
`SELECT target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, route_json
|
|
141
192
|
FROM remote_ask_callbacks
|
|
142
193
|
ORDER BY created_at ASC`,
|
|
143
194
|
)
|
|
144
195
|
.all() as DurableRemoteAskCallbackRow[];
|
|
145
196
|
let failed = 0;
|
|
146
197
|
for (const row of rows) {
|
|
198
|
+
const route = durableRoute(row);
|
|
199
|
+
if (!route) {
|
|
200
|
+
db.query("DELETE FROM remote_ask_callbacks WHERE target_query_id = ?").run(
|
|
201
|
+
row.target_query_id,
|
|
202
|
+
);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (options.routes === "hub" && !isHubRemoteReplyRoute(route)) continue;
|
|
147
206
|
const delivered = await deliverPeerReply(
|
|
148
|
-
|
|
149
|
-
nodeName: row.node_name,
|
|
150
|
-
nodeCellId: row.node_cell_id,
|
|
151
|
-
topicId: row.topic_id,
|
|
152
|
-
userId: row.user_id,
|
|
153
|
-
requestId: row.request_id,
|
|
154
|
-
},
|
|
207
|
+
route,
|
|
155
208
|
"peer",
|
|
156
209
|
"The remote worker restarted before this ask completed. Please retry the request.",
|
|
157
210
|
"error",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
22
|
closeSync,
|
|
23
|
+
existsSync,
|
|
23
24
|
openSync,
|
|
24
25
|
readdirSync,
|
|
25
26
|
readFileSync,
|
|
@@ -236,6 +237,11 @@ export async function flushBashrsCompletions(): Promise<void> {
|
|
|
236
237
|
continue;
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
// Another consumer of the same spill dir (a node and an embedding host can
|
|
241
|
+
// both hold leadership, each under its own lease store) may have finished
|
|
242
|
+
// this job between our read and now. Nothing left to deliver.
|
|
243
|
+
if (!existsSync(resultPath)) continue;
|
|
244
|
+
|
|
239
245
|
try {
|
|
240
246
|
// The stable bash_id is carried through so at-least-once delivery on
|
|
241
247
|
// either side — this watcher crashing before the rename, or the sink's
|
|
@@ -246,7 +252,19 @@ export async function flushBashrsCompletions(): Promise<void> {
|
|
|
246
252
|
bashId: result.bash_id,
|
|
247
253
|
message: buildMessage(dir, result),
|
|
248
254
|
});
|
|
249
|
-
|
|
255
|
+
try {
|
|
256
|
+
renameSync(resultPath, marker);
|
|
257
|
+
} catch (renameErr) {
|
|
258
|
+
// ENOENT here means a concurrent consumer already marked the job
|
|
259
|
+
// delivered (the sink collapses the duplicate by bash_id). That is the
|
|
260
|
+
// success state, not a failure to retry.
|
|
261
|
+
if ((renameErr as NodeJS.ErrnoException).code !== "ENOENT") throw renameErr;
|
|
262
|
+
logger.debug(
|
|
263
|
+
{ bashId: result.bash_id },
|
|
264
|
+
"bashrs-completions: already delivered by another consumer",
|
|
265
|
+
);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
250
268
|
logger.info(
|
|
251
269
|
{ bashId: result.bash_id, userId: parsed.userId, topicId: parsed.topicId },
|
|
252
270
|
"bashrs-completions: delivered",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extra `GET /health` capability strings contributed by whatever is loaded
|
|
3
|
+
* alongside the node — an adapter that forwards a new gateway route over its
|
|
4
|
+
* relay, for instance. The core route list is fixed in `control.ts`; this
|
|
5
|
+
* registry lets a host advertise a capability only when the piece that makes
|
|
6
|
+
* it true is actually present, so a hub can tell "the node supports it" from
|
|
7
|
+
* "the node *and* its relay support it".
|
|
8
|
+
*/
|
|
9
|
+
const registered = new Set<string>();
|
|
10
|
+
|
|
11
|
+
export function registerRuntimeGatewayCapability(name: string): () => void {
|
|
12
|
+
const capability = name.trim();
|
|
13
|
+
if (!capability) throw new Error("capability name is required");
|
|
14
|
+
registered.add(capability);
|
|
15
|
+
return () => {
|
|
16
|
+
registered.delete(capability);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function listRuntimeGatewayCapabilities(): string[] {
|
|
21
|
+
return [...registered].sort();
|
|
22
|
+
}
|