negotium 0.2.4 → 0.2.6
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 +241 -127
- package/dist/agent-helpers.js.map +19 -19
- package/dist/background-bash.js +1 -3
- package/dist/background-bash.js.map +4 -4
- package/dist/browser-runtime.js +1 -3
- package/dist/browser-runtime.js.map +4 -4
- package/dist/{chunk-1s9ryz8g.js → chunk-qmbcbhyy.js} +20 -12
- package/dist/chunk-qmbcbhyy.js.map +35 -0
- package/dist/hosted-agent.js +13 -10
- package/dist/hosted-agent.js.map +10 -10
- package/dist/main.js +607 -438
- package/dist/main.js.map +30 -29
- package/dist/mcp-factories.js +238 -123
- package/dist/mcp-factories.js.map +18 -18
- package/dist/prompts.js +1 -3
- package/dist/prompts.js.map +4 -4
- package/dist/query-runtime.js +1 -3
- package/dist/query-runtime.js.map +5 -5
- package/dist/registry.js +3 -3
- package/dist/registry.js.map +2 -2
- package/dist/rollout.js +1 -1
- package/dist/runtime/src/agents/api-topic-agent-switch.ts +6 -12
- package/dist/runtime/src/agents/claude-provider.ts +2 -2
- package/dist/runtime/src/agents/codex-provider.ts +1 -0
- package/dist/runtime/src/agents/index.ts +19 -14
- package/dist/runtime/src/agents/maestro-provider.ts +1 -1
- package/dist/runtime/src/agents/rollout/codex.ts +25 -18
- package/dist/runtime/src/agents/self-config-core.ts +2 -4
- package/dist/runtime/src/agents/topic-agent-switch.ts +4 -10
- package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +91 -19
- package/dist/runtime/src/application/switch-topic-model.ts +7 -10
- package/dist/runtime/src/index.ts +4 -0
- package/dist/runtime/src/mcp/wiki-server.ts +19 -12
- package/dist/runtime/src/platform/config.ts +0 -2
- package/dist/runtime/src/platform/log-rotation.ts +45 -0
- package/dist/runtime/src/platform/mcp-config.ts +9 -3
- package/dist/runtime/src/query/active-rooms.ts +7 -1
- package/dist/runtime/src/runtime/errors.ts +1 -0
- package/dist/runtime/src/runtime/turn-event-stream.ts +46 -1
- package/dist/runtime/src/runtime/turn-runner.ts +176 -100
- package/dist/runtime/src/runtime/user-turn-envelope.ts +19 -0
- package/dist/runtime/src/storage/runtime-gateway-submissions.ts +32 -3
- package/dist/runtime/src/storage/runtime-turn-requests.ts +68 -7
- package/dist/runtime/src/types.ts +2 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-helpers.js +1 -3
- package/dist/runtime-helpers.js.map +4 -4
- package/dist/storage.js.map +1 -1
- package/dist/types/packages/core/src/agents/api-topic-agent-switch.d.ts +1 -1
- package/dist/types/packages/core/src/agents/self-config-core.d.ts +1 -1
- package/dist/types/packages/core/src/platform/config.d.ts +0 -1
- package/dist/types/packages/core/src/platform/mcp-config.d.ts +3 -0
- package/dist/types/packages/core/src/query/active-rooms.d.ts +7 -1
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +42 -1
- package/dist/types/packages/core/src/runtime/user-turn-envelope.d.ts +7 -0
- package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +17 -0
- package/dist/types/packages/core/src/types.d.ts +2 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js +1 -3
- package/dist/vault.js.map +4 -4
- package/package.json +1 -1
- package/dist/chunk-1s9ryz8g.js.map +0 -35
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import type { AgentKind, EffortLevel } from "#types";
|
|
14
14
|
|
|
15
15
|
export type ApiTopicSwitchOutcome =
|
|
16
|
-
| { kind: "fresh"; agent: AgentKind; reason: "no-history"
|
|
16
|
+
| { kind: "fresh"; agent: AgentKind; reason: "no-history" }
|
|
17
17
|
| { kind: "bridged"; agent: AgentKind; bridgedSessionId: string; rolloutPath: string };
|
|
18
18
|
|
|
19
19
|
export type ApiTopicSwitchResult =
|
|
@@ -119,18 +119,12 @@ export function switchApiTopicAgent(opts: SwitchApiTopicAgentOptions): ApiTopicS
|
|
|
119
119
|
from: opts.fromAgent,
|
|
120
120
|
to: opts.agent,
|
|
121
121
|
},
|
|
122
|
-
"api-topic switch: rollout encoding failed —
|
|
122
|
+
"api-topic switch: rollout encoding failed — preserving current agent session",
|
|
123
123
|
);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{ err: commitErr, topicId: opts.topicId, agent: opts.agent },
|
|
129
|
-
"api-topic switch fallback commit failed",
|
|
130
|
-
);
|
|
131
|
-
return { ok: false, error: `failed to set agent for "${opts.topicTitle}"` };
|
|
132
|
-
}
|
|
133
|
-
return { ok: true, outcome: { kind: "fresh", agent: opts.agent, reason: "bridge-failed" } };
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: `failed to preserve conversation while switching agent for "${opts.topicTitle}"`,
|
|
127
|
+
};
|
|
134
128
|
}
|
|
135
129
|
|
|
136
130
|
try {
|
|
@@ -450,7 +450,7 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
|
|
|
450
450
|
// Resolve Vault placeholders only for explicitly allowlisted
|
|
451
451
|
// transient execution tools. Messaging and persistence tools
|
|
452
452
|
// must retain {{KEY}} verbatim.
|
|
453
|
-
const userId = opts.userId ?? "";
|
|
453
|
+
const userId = opts.vaultUserId ?? opts.userId ?? "";
|
|
454
454
|
const withVault = substituteClaudeToolInput(userId, tool_name, tool_input);
|
|
455
455
|
if (JSON.stringify(withVault) === JSON.stringify(tool_input)) {
|
|
456
456
|
return { continue: true };
|
|
@@ -470,7 +470,7 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
|
|
|
470
470
|
hooks: [
|
|
471
471
|
async (input: HookInput) => {
|
|
472
472
|
const { tool_response } = input as PostToolUseHookInput;
|
|
473
|
-
const userId = opts.userId ?? "";
|
|
473
|
+
const userId = opts.vaultUserId ?? opts.userId ?? "";
|
|
474
474
|
const redacted = deepMapStrings(tool_response, (value) =>
|
|
475
475
|
redactHostedSecrets(userId, value),
|
|
476
476
|
);
|
|
@@ -774,6 +774,7 @@ export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<Un
|
|
|
774
774
|
} catch (err) {
|
|
775
775
|
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
776
776
|
if (!currentSessionId || !isMissingCodexRolloutError(err)) throw err;
|
|
777
|
+
if (opts.userId && opts.session) throw err;
|
|
777
778
|
logger.warn(
|
|
778
779
|
{ staleSessionId: currentSessionId, err: errMsg(err) },
|
|
779
780
|
"codexProvider: stale/missing rollout, restarting fresh thread",
|
|
@@ -129,18 +129,17 @@ export async function resolveSessionFileMissing(
|
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* Rebuild a missing SDK session file from the unified conversation log, or
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* should start a fresh session (no entries, rebuild failed, etc.).
|
|
132
|
+
* report whether the session is ready, genuinely has no history, or could not
|
|
133
|
+
* be repaired. Repair failures must not silently discard conversation context.
|
|
135
134
|
*/
|
|
136
135
|
async function maybeRebuildSession(
|
|
137
136
|
opts: AgentQueryOptions & { userId: string; session: string },
|
|
138
|
-
): Promise<
|
|
139
|
-
if (!opts.sessionId) return
|
|
137
|
+
): Promise<"ready" | "fresh" | "failed"> {
|
|
138
|
+
if (!opts.sessionId) return "ready"; // fresh session, nothing to rebuild
|
|
140
139
|
|
|
141
140
|
try {
|
|
142
141
|
const missing = await resolveSessionFileMissing(opts.agent, opts.sessionId, opts.cwd);
|
|
143
|
-
if (!missing) return
|
|
142
|
+
if (!missing) return "ready"; // file still on disk
|
|
144
143
|
|
|
145
144
|
logger.info(
|
|
146
145
|
{ agent: opts.agent, sessionId: opts.sessionId, topic: opts.session },
|
|
@@ -153,7 +152,7 @@ async function maybeRebuildSession(
|
|
|
153
152
|
{ agent: opts.agent, sessionId: opts.sessionId },
|
|
154
153
|
"no unified log entries to rebuild — starting fresh session",
|
|
155
154
|
);
|
|
156
|
-
return
|
|
155
|
+
return "fresh";
|
|
157
156
|
}
|
|
158
157
|
|
|
159
158
|
const operations = getRegistryOperations(opts.agent);
|
|
@@ -168,13 +167,13 @@ async function maybeRebuildSession(
|
|
|
168
167
|
{ agent: opts.agent, sessionId: result.sessionId, rolloutPath: result.rolloutPath },
|
|
169
168
|
"session rebuilt from unified log",
|
|
170
169
|
);
|
|
171
|
-
return
|
|
170
|
+
return "ready";
|
|
172
171
|
} catch (err) {
|
|
173
172
|
logger.warn(
|
|
174
173
|
{ err, agent: opts.agent, sessionId: opts.sessionId },
|
|
175
|
-
"session rebuild failed —
|
|
174
|
+
"session rebuild failed — preserving resume key",
|
|
176
175
|
);
|
|
177
|
-
return
|
|
176
|
+
return "failed";
|
|
178
177
|
}
|
|
179
178
|
}
|
|
180
179
|
|
|
@@ -183,11 +182,17 @@ export async function* runAgent(opts: AgentQueryOptions): AsyncGenerator<Unified
|
|
|
183
182
|
const dispatchOpts: AgentQueryOptions = { ...opts };
|
|
184
183
|
|
|
185
184
|
if (hasSessionRepairContext(dispatchOpts)) {
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
//
|
|
189
|
-
// a fresh session instead of trying to resume a non-existent one.
|
|
185
|
+
const repair = await maybeRebuildSession(dispatchOpts);
|
|
186
|
+
if (repair === "fresh") {
|
|
187
|
+
// No unified history exists, so a new provider session is intentional.
|
|
190
188
|
dispatchOpts.sessionId = undefined;
|
|
189
|
+
} else if (repair === "failed") {
|
|
190
|
+
yield {
|
|
191
|
+
type: "error",
|
|
192
|
+
content:
|
|
193
|
+
"Provider session could not be rebuilt from conversation history; the existing session was preserved for retry.",
|
|
194
|
+
};
|
|
195
|
+
return;
|
|
191
196
|
}
|
|
192
197
|
}
|
|
193
198
|
|
|
@@ -242,7 +242,7 @@ export function maestroProvider(opts: AgentQueryOptions): AsyncGenerator<Unified
|
|
|
242
242
|
if (opts.agent !== undefined && opts.agent !== "maestro") {
|
|
243
243
|
throw new Error(`maestroProvider: unexpected agent "${opts.agent}", expected "maestro"`);
|
|
244
244
|
}
|
|
245
|
-
const userId = opts.userId ?? "";
|
|
245
|
+
const userId = opts.vaultUserId ?? opts.userId ?? "";
|
|
246
246
|
// The SDK deliberately ships with an empty MCP resolver. Keep this wiring in
|
|
247
247
|
// the provider adapter rather than every host bootstrap so a new Negotium
|
|
248
248
|
// channel cannot accidentally run Maestro with zero runtime/wiki/task MCPs.
|
|
@@ -157,6 +157,19 @@ export interface CodexPatchPreview {
|
|
|
157
157
|
changes: CodexPatchChangePreview[];
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
function codexRolloutPath(threadId: string, fallback: Date): string {
|
|
161
|
+
const threadCreatedAt = decodeUuidV7Timestamp(threadId);
|
|
162
|
+
const createdAt = threadCreatedAt === null ? fallback : new Date(threadCreatedAt);
|
|
163
|
+
const yyyy = String(createdAt.getFullYear());
|
|
164
|
+
const mm = String(createdAt.getMonth() + 1).padStart(2, "0");
|
|
165
|
+
const dd = String(createdAt.getDate()).padStart(2, "0");
|
|
166
|
+
const hh = String(createdAt.getHours()).padStart(2, "0");
|
|
167
|
+
const min = String(createdAt.getMinutes()).padStart(2, "0");
|
|
168
|
+
const ss = String(createdAt.getSeconds()).padStart(2, "0");
|
|
169
|
+
const dir = join(codexSessionsDir(), yyyy, mm, dd);
|
|
170
|
+
return join(dir, `rollout-${yyyy}-${mm}-${dd}T${hh}-${min}-${ss}-${threadId}.jsonl`);
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
function canonicalFilePath(path: string): string {
|
|
161
174
|
const absolute = resolve(path);
|
|
162
175
|
try {
|
|
@@ -564,22 +577,17 @@ export function writeCodexRollout(opts: CodexRolloutOptions): CodexRolloutResult
|
|
|
564
577
|
payload: { type: "task_complete" },
|
|
565
578
|
});
|
|
566
579
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const
|
|
572
|
-
const path = join(dir, `rollout-${tsStr}-${threadId}.jsonl`);
|
|
580
|
+
// Codex indexes a thread by the local wall-clock timestamp encoded in its
|
|
581
|
+
// UUIDv7 creation time. A cross-agent round-trip may rewrite the rollout
|
|
582
|
+
// much later, but the path must remain the original canonical path or
|
|
583
|
+
// `thread/resume` rejects it as missing and the provider starts fresh.
|
|
584
|
+
const path = codexRolloutPath(threadId, now);
|
|
573
585
|
|
|
574
586
|
// Round-trip cleanup: when the caller reused a threadId (claude→codex→...
|
|
575
587
|
// or maestro→codex→... cycles via `findLastSessionIdForAgent`), the SDK's
|
|
576
|
-
// original rollout for that threadId still sits at its
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
// the same threadId — `codex resume <tid>` globs `~/.codex/sessions/**`
|
|
580
|
-
// and the SDK has no spec for which match it picks, so behavior becomes
|
|
581
|
-
// environment-/codex-version-dependent. Sweep every prior file for this
|
|
582
|
-
// threadId BEFORE writing the new one so resume always reads our synth.
|
|
588
|
+
// original rollout for that threadId still sits at its canonical path.
|
|
589
|
+
// Sweep every prior file for this threadId BEFORE replacing that path so
|
|
590
|
+
// legacy writers cannot leave ambiguous duplicates behind.
|
|
583
591
|
//
|
|
584
592
|
// Synchronous on purpose: writeCodexRollout's signature is sync (called
|
|
585
593
|
// inside set_agent's DB transaction), and an async sweep would race with
|
|
@@ -672,11 +680,10 @@ function sweepPriorRolloutsFullTree(threadId: string, sessionsDir: string): void
|
|
|
672
680
|
}
|
|
673
681
|
|
|
674
682
|
/**
|
|
675
|
-
* UUIDv7 ids encode their creation time in the first 48 bits.
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* inclusive range so a Mon-bridge / Tue-rebridge picks up both.
|
|
683
|
+
* UUIDv7 ids encode their creation time in the first 48 bits. Current writers
|
|
684
|
+
* always use that canonical birth-date bucket, but older writers used the
|
|
685
|
+
* rewrite date during cross-agent bridges. Enumerate the inclusive range from
|
|
686
|
+
* birth date to today so cleanup also removes those legacy misplaced files.
|
|
680
687
|
*
|
|
681
688
|
* Returns null when:
|
|
682
689
|
* - threadId isn't a parseable UUIDv7 (atypical fixture / future format)
|
|
@@ -89,7 +89,7 @@ export type SelfConfigAgentSwitchResult =
|
|
|
89
89
|
| {
|
|
90
90
|
ok: true;
|
|
91
91
|
outcome:
|
|
92
|
-
| { kind: "fresh"; agent: AgentKind; reason: "no-history"
|
|
92
|
+
| { kind: "fresh"; agent: AgentKind; reason: "no-history" }
|
|
93
93
|
| {
|
|
94
94
|
kind: "bridged";
|
|
95
95
|
agent: AgentKind;
|
|
@@ -386,9 +386,7 @@ export function createSelfConfigCore(
|
|
|
386
386
|
const sessionNote =
|
|
387
387
|
switched.outcome.kind === "bridged"
|
|
388
388
|
? ` Conversation history was bridged into a ${agent} session (${switched.outcome.bridgedSessionId}).`
|
|
389
|
-
:
|
|
390
|
-
? " Conversation history bridge failed; the next turn starts fresh."
|
|
391
|
-
: " No prior conversation history was found; the next turn starts fresh.";
|
|
389
|
+
: " No prior conversation history was found; the next turn starts fresh.";
|
|
392
390
|
return ok(
|
|
393
391
|
`Agent for this topic set to '${agent}'. Model/effort reset to '${agent}' defaults.${sessionNote}`,
|
|
394
392
|
);
|
|
@@ -39,7 +39,7 @@ export type SwitchAgentOutcome =
|
|
|
39
39
|
| {
|
|
40
40
|
kind: "fresh";
|
|
41
41
|
agent: AgentKind;
|
|
42
|
-
reason: "no-history"
|
|
42
|
+
reason: "no-history";
|
|
43
43
|
}
|
|
44
44
|
| {
|
|
45
45
|
kind: "bridged";
|
|
@@ -128,17 +128,11 @@ export function switchTopicAgent(
|
|
|
128
128
|
} catch (err) {
|
|
129
129
|
logger.warn(
|
|
130
130
|
{ err, userId, topicName, from: oldAgent, to: agent },
|
|
131
|
-
"switchTopicAgent: rollout encoding failed —
|
|
131
|
+
"switchTopicAgent: rollout encoding failed — preserving current agent session",
|
|
132
132
|
);
|
|
133
|
-
const changed = setTopicAgentAndClearSession({
|
|
134
|
-
userId,
|
|
135
|
-
topicName,
|
|
136
|
-
agent,
|
|
137
|
-
});
|
|
138
|
-
if (!changed) return { ok: false, error: `failed to set agent for "${topicName}"` };
|
|
139
133
|
return {
|
|
140
|
-
ok:
|
|
141
|
-
|
|
134
|
+
ok: false,
|
|
135
|
+
error: `failed to preserve conversation while switching agent for "${topicName}"`,
|
|
142
136
|
};
|
|
143
137
|
}
|
|
144
138
|
|
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { appendApiMessage, getApiMessage } from "#storage/api-messages";
|
|
3
3
|
import { getTopicSessionId } from "#storage/api-topics";
|
|
4
4
|
import { db } from "#storage/forum-db";
|
|
5
5
|
import { appendRuntimeEvent } from "#storage/runtime-events";
|
|
6
6
|
import {
|
|
7
|
+
backfillRuntimeGatewaySubmissionPayloadHash,
|
|
7
8
|
findRuntimeGatewaySubmission,
|
|
8
9
|
type RuntimeGatewaySubmission,
|
|
9
10
|
recordRuntimeGatewaySubmission,
|
|
10
11
|
} from "#storage/runtime-gateway-submissions";
|
|
11
|
-
import {
|
|
12
|
+
import { requestRuntimeTurnAbort } from "#storage/runtime-leases";
|
|
13
|
+
import { getRuntimeTopicEpoch } from "#storage/runtime-topic-state";
|
|
14
|
+
import { mergeRuntimeUserTurnRequest } from "#storage/runtime-turn-requests";
|
|
12
15
|
import type { MessageDto, TopicDto } from "#types/api";
|
|
13
16
|
|
|
14
17
|
export interface SubmitRuntimeGatewayTurnParams {
|
|
15
18
|
topic: TopicDto;
|
|
19
|
+
/** Canonical execution principal, normally `local`. */
|
|
16
20
|
userId: string;
|
|
21
|
+
/** Authenticated upstream author retained independently from execution. */
|
|
22
|
+
actorUserId?: string;
|
|
23
|
+
actorLabel?: string;
|
|
24
|
+
/** Topic owner's credential namespace. */
|
|
25
|
+
vaultUserId?: string;
|
|
17
26
|
text: string;
|
|
18
27
|
clientMessageId: string;
|
|
19
28
|
requestId?: string;
|
|
@@ -25,16 +34,72 @@ export interface SubmitRuntimeGatewayTurnResult extends RuntimeGatewaySubmission
|
|
|
25
34
|
deduplicated: boolean;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
|
-
function duplicateResult(
|
|
37
|
+
function duplicateResult(
|
|
38
|
+
submission: RuntimeGatewaySubmission,
|
|
39
|
+
params: SubmitRuntimeGatewayTurnParams,
|
|
40
|
+
requestId: string,
|
|
41
|
+
actorUserId: string,
|
|
42
|
+
payloadHash: string,
|
|
43
|
+
): SubmitRuntimeGatewayTurnResult {
|
|
29
44
|
const message = getApiMessage(submission.topicId, submission.messageId);
|
|
30
45
|
if (!message) throw new Error("gateway submission references a missing canonical message");
|
|
46
|
+
if (submission.payloadHash && submission.payloadHash !== payloadHash) {
|
|
47
|
+
throw new RuntimeGatewayIdempotencyConflictError();
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
// `MessageDto.authorName` is never persisted — `api_messages` has no
|
|
51
|
+
// `author_name` column, so a message fetched back from storage always
|
|
52
|
+
// has `authorName: undefined`. Comparing it here would either always
|
|
53
|
+
// fail (once `actorLabel` is set) or always pass (once it's cleared);
|
|
54
|
+
// it is dropped in favor of the fields that actually round-trip.
|
|
55
|
+
(!submission.payloadHash &&
|
|
56
|
+
(message.authorId !== actorUserId || message.text !== params.text)) ||
|
|
57
|
+
submission.clientMessageId !== params.clientMessageId ||
|
|
58
|
+
submission.requestId !== requestId ||
|
|
59
|
+
submission.topicId !== params.topic.id ||
|
|
60
|
+
submission.userId !== params.userId
|
|
61
|
+
) {
|
|
62
|
+
throw new RuntimeGatewayIdempotencyConflictError();
|
|
63
|
+
}
|
|
64
|
+
if (!submission.payloadHash) {
|
|
65
|
+
// Pre-0.2.5 rows never recorded `actorLabel`/`vaultUserId`/
|
|
66
|
+
// `allowAutoContinue` anywhere retrievable, so this replay could not
|
|
67
|
+
// actually be checked against them above. Adopt this call's hash as
|
|
68
|
+
// canonical for the key now, so any *later* replay with a different
|
|
69
|
+
// actor label, Vault, or auto-continue flag is caught instead of
|
|
70
|
+
// silently reusing this ACK forever.
|
|
71
|
+
backfillRuntimeGatewaySubmissionPayloadHash(submission.clientMessageId, payloadHash);
|
|
72
|
+
}
|
|
31
73
|
return {
|
|
32
74
|
...submission,
|
|
75
|
+
payloadHash: submission.payloadHash ?? payloadHash,
|
|
33
76
|
message,
|
|
34
77
|
deduplicated: true,
|
|
35
78
|
};
|
|
36
79
|
}
|
|
37
80
|
|
|
81
|
+
function gatewayPayloadHash(
|
|
82
|
+
params: SubmitRuntimeGatewayTurnParams,
|
|
83
|
+
requestId: string,
|
|
84
|
+
actorUserId: string,
|
|
85
|
+
): string {
|
|
86
|
+
return createHash("sha256")
|
|
87
|
+
.update(
|
|
88
|
+
JSON.stringify([
|
|
89
|
+
params.topic.id,
|
|
90
|
+
params.userId,
|
|
91
|
+
actorUserId,
|
|
92
|
+
params.actorLabel ?? null,
|
|
93
|
+
params.vaultUserId ?? null,
|
|
94
|
+
params.text,
|
|
95
|
+
params.clientMessageId,
|
|
96
|
+
requestId,
|
|
97
|
+
params.allowAutoContinue ?? true,
|
|
98
|
+
]),
|
|
99
|
+
)
|
|
100
|
+
.digest("hex");
|
|
101
|
+
}
|
|
102
|
+
|
|
38
103
|
/**
|
|
39
104
|
* Durable ingress for an authenticated external gateway. It deliberately
|
|
40
105
|
* reuses the canonical message table and existing durable turn worker rather
|
|
@@ -45,8 +110,7 @@ function duplicateResult(submission: RuntimeGatewaySubmission): SubmitRuntimeGat
|
|
|
45
110
|
*
|
|
46
111
|
* A distinct type rather than a plain `Error` because the control plane maps
|
|
47
112
|
* this to 409, and it must not be confused with a *missing* `clientMessageId`,
|
|
48
|
-
* which is a malformed request (400).
|
|
49
|
-
* two.
|
|
113
|
+
* which is a malformed request (400).
|
|
50
114
|
*/
|
|
51
115
|
export class RuntimeGatewayIdempotencyConflictError extends Error {
|
|
52
116
|
constructor(message = "clientMessageId or requestId is already bound to another turn") {
|
|
@@ -59,24 +123,19 @@ export function submitRuntimeGatewayTurn(
|
|
|
59
123
|
params: SubmitRuntimeGatewayTurnParams,
|
|
60
124
|
): SubmitRuntimeGatewayTurnResult {
|
|
61
125
|
const requestId = params.requestId ?? params.clientMessageId;
|
|
126
|
+
const actorUserId = params.actorUserId ?? params.userId;
|
|
127
|
+
const payloadHash = gatewayPayloadHash(params, requestId, actorUserId);
|
|
62
128
|
const existing = findRuntimeGatewaySubmission(params.clientMessageId, requestId);
|
|
63
129
|
if (existing) {
|
|
64
|
-
|
|
65
|
-
existing.clientMessageId !== params.clientMessageId ||
|
|
66
|
-
existing.requestId !== requestId ||
|
|
67
|
-
existing.topicId !== params.topic.id ||
|
|
68
|
-
existing.userId !== params.userId
|
|
69
|
-
) {
|
|
70
|
-
throw new RuntimeGatewayIdempotencyConflictError();
|
|
71
|
-
}
|
|
72
|
-
return duplicateResult(existing);
|
|
130
|
+
return duplicateResult(existing, params, requestId, actorUserId, payloadHash);
|
|
73
131
|
}
|
|
74
132
|
|
|
75
133
|
const createdAt = new Date().toISOString();
|
|
76
134
|
const message: MessageDto = {
|
|
77
135
|
id: randomUUID(),
|
|
78
136
|
topicId: params.topic.id,
|
|
79
|
-
authorId:
|
|
137
|
+
authorId: actorUserId,
|
|
138
|
+
authorName: params.actorLabel,
|
|
80
139
|
sourceAdapter: "runtime-gateway",
|
|
81
140
|
sourceMessageId: params.clientMessageId,
|
|
82
141
|
text: params.text,
|
|
@@ -91,23 +150,31 @@ export function submitRuntimeGatewayTurn(
|
|
|
91
150
|
createdAt,
|
|
92
151
|
ackCursor: 0,
|
|
93
152
|
messageCursor: 0,
|
|
153
|
+
payloadHash,
|
|
94
154
|
};
|
|
95
155
|
|
|
96
156
|
try {
|
|
97
157
|
db.transaction(() => {
|
|
98
158
|
appendApiMessage(message, { notify: false });
|
|
99
|
-
|
|
159
|
+
mergeRuntimeUserTurnRequest({
|
|
100
160
|
topicId: params.topic.id,
|
|
101
161
|
userId: params.userId,
|
|
102
|
-
|
|
162
|
+
userMessages: [
|
|
163
|
+
{
|
|
164
|
+
prompt: params.text,
|
|
165
|
+
actorUserId,
|
|
166
|
+
...(params.actorLabel ? { actorLabel: params.actorLabel } : {}),
|
|
167
|
+
},
|
|
168
|
+
],
|
|
103
169
|
allowAutoContinue: params.allowAutoContinue ?? true,
|
|
104
170
|
requestId,
|
|
105
|
-
|
|
171
|
+
topicEpoch: getRuntimeTopicEpoch(params.topic.id),
|
|
106
172
|
execution: {
|
|
107
173
|
sessionId: getTopicSessionId(params.topic.id),
|
|
108
174
|
sessionIdSpecified: true,
|
|
109
175
|
conversationPrompts: [params.text],
|
|
110
176
|
loggedUserMessageCount: 0,
|
|
177
|
+
vaultUserId: params.vaultUserId,
|
|
111
178
|
},
|
|
112
179
|
});
|
|
113
180
|
const acceptedEvent = appendRuntimeEvent("runtime-gateway-ingress", {
|
|
@@ -131,9 +198,14 @@ export function submitRuntimeGatewayTurn(
|
|
|
131
198
|
})();
|
|
132
199
|
} catch {
|
|
133
200
|
const raced = findRuntimeGatewaySubmission(params.clientMessageId, requestId);
|
|
134
|
-
if (raced) return duplicateResult(raced);
|
|
201
|
+
if (raced) return duplicateResult(raced, params, requestId, actorUserId, payloadHash);
|
|
135
202
|
throw new Error("failed to persist gateway turn idempotency record");
|
|
136
203
|
}
|
|
137
204
|
|
|
205
|
+
// A new human message steers the active topic turn. The durable replacement
|
|
206
|
+
// is committed first; the provider observes this abort on its next lease
|
|
207
|
+
// heartbeat and the worker resumes the merged batch after unwind.
|
|
208
|
+
requestRuntimeTurnAbort(params.topic.id, "internal");
|
|
209
|
+
|
|
138
210
|
return { ...submission, message, deduplicated: false };
|
|
139
211
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { switchApiTopicAgent } from "#agents/api-topic-agent-switch";
|
|
2
2
|
import { checkAgentModelAuth } from "#agents/auth-check";
|
|
3
|
-
import {
|
|
4
|
-
import { getRegistry } from "#agents/registry";
|
|
3
|
+
import { selectableModel } from "#agents/model-catalog";
|
|
5
4
|
import { WsHub } from "#bus";
|
|
6
5
|
import { resolveTopicWorkspaceDir } from "#platform/config";
|
|
7
6
|
import { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
|
|
8
|
-
import {
|
|
7
|
+
import { getTopic } from "#storage/api-topics";
|
|
9
8
|
|
|
10
9
|
export type SwitchTopicModelResult =
|
|
11
10
|
| { ok: true; model: string; text: string }
|
|
@@ -63,14 +62,12 @@ export function switchTopicModel(params: SwitchTopicModelParams): SwitchTopicMod
|
|
|
63
62
|
});
|
|
64
63
|
if (!switched.ok) return { ok: false, error: switched.error };
|
|
65
64
|
} else {
|
|
66
|
-
const registry = getRegistry(currentAgent);
|
|
67
|
-
const previousModel = resolveModelForAgent(
|
|
68
|
-
currentAgent,
|
|
69
|
-
currentConfig.model ?? topic.defaultModel,
|
|
70
|
-
registry,
|
|
71
|
-
);
|
|
72
65
|
setApiTopicConfig(topic.id, nextConfig);
|
|
73
|
-
|
|
66
|
+
// A provider session is not owned by one model. Codex, Claude, and Maestro
|
|
67
|
+
// all accept the selected model when resuming, so keep the durable resume
|
|
68
|
+
// key and let the next turn continue the same rollout with the new model.
|
|
69
|
+
// Clearing it here silently discarded native tool/history structure and
|
|
70
|
+
// made an ordinary model change look like `/new`.
|
|
74
71
|
}
|
|
75
72
|
|
|
76
73
|
WsHub.get().broadcastTopicUpdated(topic.id);
|
|
@@ -176,6 +176,10 @@ export { FROM_AUTO_CONTINUE } from "#platform/constants";
|
|
|
176
176
|
export { errMsg } from "#platform/error";
|
|
177
177
|
export { appendJsonlEntry } from "#platform/jsonl";
|
|
178
178
|
export { onShutdown, runShutdown } from "#platform/lifecycle";
|
|
179
|
+
export {
|
|
180
|
+
DEFAULT_DAEMON_LOG_ROTATE_BYTES,
|
|
181
|
+
rotateOversizedLog,
|
|
182
|
+
} from "#platform/log-rotation";
|
|
179
183
|
export { logger } from "#platform/logger";
|
|
180
184
|
export type {
|
|
181
185
|
NodeMcpEntry,
|
|
@@ -439,23 +439,28 @@ function wikiLastConversation(args: Record<string, unknown>): CallToolResult {
|
|
|
439
439
|
|
|
440
440
|
const name = slugify(topicNameFrom(rawTopic));
|
|
441
441
|
|
|
442
|
-
// Try per-topic archive dir first, then flat file
|
|
442
|
+
// Try per-topic archive dir first, then flat file. This is a read path —
|
|
443
|
+
// it must never create directories on lookup (that previously left an
|
|
444
|
+
// empty `wiki/archive/<topic>/` behind for every topic name ever queried,
|
|
445
|
+
// even when nothing was ever archived under the per-topic layout).
|
|
443
446
|
const archiveDir = resolve(runtime().archiveDir, name);
|
|
444
|
-
ensureDir(archiveDir);
|
|
445
447
|
|
|
446
448
|
try {
|
|
447
449
|
// List archive files sorted by name (which should be date-sorted)
|
|
448
|
-
let files =
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
450
|
+
let files = existsSync(archiveDir)
|
|
451
|
+
? readdirSync(archiveDir)
|
|
452
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
453
|
+
.sort()
|
|
454
|
+
.reverse()
|
|
455
|
+
: [];
|
|
452
456
|
if (files.length === 0) {
|
|
453
457
|
// Try flat archive
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
458
|
+
files = existsSync(runtime().archiveDir)
|
|
459
|
+
? readdirSync(runtime().archiveDir)
|
|
460
|
+
.filter((f) => f.startsWith(name) && f.endsWith(".jsonl"))
|
|
461
|
+
.sort()
|
|
462
|
+
.reverse()
|
|
463
|
+
: [];
|
|
459
464
|
}
|
|
460
465
|
if (files.length === 0) {
|
|
461
466
|
return {
|
|
@@ -467,7 +472,9 @@ function wikiLastConversation(args: Record<string, unknown>): CallToolResult {
|
|
|
467
472
|
const actualPath = files[0].includes("/")
|
|
468
473
|
? files[0]
|
|
469
474
|
: resolve(
|
|
470
|
-
readdirSync(archiveDir).includes(files[0])
|
|
475
|
+
existsSync(archiveDir) && readdirSync(archiveDir).includes(files[0])
|
|
476
|
+
? archiveDir
|
|
477
|
+
: runtime().archiveDir,
|
|
471
478
|
files[0],
|
|
472
479
|
);
|
|
473
480
|
|
|
@@ -75,7 +75,6 @@ export const BROWSER_PROFILES_DIR = resolve(BROWSER_DIR, "profiles");
|
|
|
75
75
|
export const BINARIES_DIR = resolve(STATE_DIR, "binaries");
|
|
76
76
|
export const SECRETS_DIR = resolve(STATE_DIR, "secrets");
|
|
77
77
|
// Legacy names remain public, but their durable contents now live under data.
|
|
78
|
-
export const CONTEXTS_DIR = resolve(STATE_DIR, "data", "contexts");
|
|
79
78
|
export const DM_WORKSPACE_DIR = resolve(STATE_DIR, "data", "dm");
|
|
80
79
|
export const SESSION_WORKSPACE_DIR = resolve(STATE_DIR, "data", "sessions");
|
|
81
80
|
// The Claude Agent SDK ships a platform-matched Claude Code binary. Keep that
|
|
@@ -364,7 +363,6 @@ mkdirSync(WORKSPACE_DIR, { recursive: true });
|
|
|
364
363
|
mkdirSync(TOPIC_WORKSPACE_DIR, { recursive: true });
|
|
365
364
|
mkdirSync(SHARED_WIKI_DIR, { recursive: true });
|
|
366
365
|
mkdirSync(CRON_WORKSPACE_DIR, { recursive: true });
|
|
367
|
-
mkdirSync(CONTEXTS_DIR, { recursive: true });
|
|
368
366
|
mkdirSync(DM_WORKSPACE_DIR, { recursive: true });
|
|
369
367
|
mkdirSync(SESSION_WORKSPACE_DIR, { recursive: true });
|
|
370
368
|
mkdirSync(BROWSER_DIR, { recursive: true });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size-based rotation for daemon stderr logs.
|
|
3
|
+
*
|
|
4
|
+
* Negotium adapters (`negotium-terminal`, `negotium-telegram`, `negotium-otium`)
|
|
5
|
+
* each spawn the node daemon with its stderr redirected straight to
|
|
6
|
+
* `<LOG_DIR>/node-daemon.log`. That file is opened once at spawn time and
|
|
7
|
+
* accumulates for the daemon's whole lifetime with no cap, so long-running
|
|
8
|
+
* hosts eventually carry an unbounded log file.
|
|
9
|
+
*
|
|
10
|
+
* True in-place rotation (SIGHUP-style reopen) would require the daemon to
|
|
11
|
+
* cooperate by reopening its own stderr fd, which Bun does not expose. The
|
|
12
|
+
* practical fix instead rotates *before* spawn: every time a CLI is about to
|
|
13
|
+
* start the daemon, check the previous log's size and, if it crossed the
|
|
14
|
+
* threshold, rename it aside with a timestamp so the new spawn starts a
|
|
15
|
+
* fresh, empty file.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, renameSync, statSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
/** Rotate once the log exceeds this size (bytes) at the next daemon start. */
|
|
22
|
+
export const DEFAULT_DAEMON_LOG_ROTATE_BYTES = 20 * 1024 * 1024; // 20MB
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* If `logPath` exists and is at least `maxBytes`, rename it aside with an
|
|
26
|
+
* ISO-ish timestamp suffix so a fresh file can be created at `logPath`.
|
|
27
|
+
* No-op (and never throws) when the file is missing, under the threshold, or
|
|
28
|
+
* the rename fails for any reason — log rotation must never block startup.
|
|
29
|
+
*/
|
|
30
|
+
export function rotateOversizedLog(
|
|
31
|
+
logPath: string,
|
|
32
|
+
maxBytes: number = DEFAULT_DAEMON_LOG_ROTATE_BYTES,
|
|
33
|
+
): void {
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(logPath)) return;
|
|
36
|
+
const { size } = statSync(logPath);
|
|
37
|
+
if (size < maxBytes) return;
|
|
38
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
39
|
+
const dir = join(logPath, "..");
|
|
40
|
+
const base = logPath.slice(dir.length + 1);
|
|
41
|
+
renameSync(logPath, join(dir, `${base}.${stamp}`));
|
|
42
|
+
} catch {
|
|
43
|
+
// Best-effort: a failed rotation should never prevent the daemon from starting.
|
|
44
|
+
}
|
|
45
|
+
}
|