negotium 0.22.2 → 0.22.4
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/bin/negotium.mjs +0 -0
- package/dist/agent-helpers.js +601 -42
- package/dist/agent-helpers.js.map +8 -6
- package/dist/{chunk-gzph93xn.js → chunk-0jtan6fd.js} +3 -3
- package/dist/{chunk-gzph93xn.js.map → chunk-0jtan6fd.js.map} +2 -2
- package/dist/{chunk-kn8y5scr.js → chunk-rwj7x5pg.js} +4 -1
- package/dist/{chunk-kn8y5scr.js.map → chunk-rwj7x5pg.js.map} +3 -3
- package/dist/{chunk-bn1tt2qw.js → chunk-y4tyc603.js} +1 -1
- package/dist/hosted-agent.js +5 -2
- package/dist/hosted-agent.js.map +4 -4
- package/dist/main.js +1145 -376
- package/dist/main.js.map +22 -20
- package/dist/mcp-factories.js +620 -61
- package/dist/mcp-factories.js.map +8 -6
- package/dist/registry.js +3 -3
- package/dist/rollout.js +2 -2
- package/dist/runtime/src/agents/mcp-tools/request-vault.ts +616 -0
- package/dist/runtime/src/application/topic-service.ts +7 -0
- package/dist/runtime/src/index.ts +9 -0
- package/dist/runtime/src/mcp-runtime-host.ts +1 -0
- package/dist/runtime/src/node-host.ts +5 -0
- package/dist/runtime/src/runtime/inbox.ts +33 -171
- package/dist/runtime/src/runtime/turn-event-stream.ts +2 -0
- package/dist/runtime/src/storage/api-messages.ts +8 -2
- package/dist/runtime/src/storage/vault-request-gates.ts +359 -0
- package/dist/runtime/src/types/api.ts +23 -1
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-helpers.js +4 -1
- package/dist/runtime-helpers.js.map +3 -3
- package/dist/storage.js +8 -4
- package/dist/storage.js.map +3 -3
- package/dist/types/packages/core/src/agents/mcp-tools/request-vault.d.ts +120 -0
- package/dist/types/packages/core/src/storage/api-messages.d.ts +1 -0
- package/dist/types/packages/core/src/storage/vault-request-gates.d.ts +69 -0
- package/dist/types/packages/core/src/types/api.d.ts +15 -1
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/package.json +1 -1
- /package/dist/{chunk-bn1tt2qw.js.map → chunk-y4tyc603.js.map} +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { createAskUserToolDefinition } from "#agents/mcp-tools/ask-user";
|
|
3
3
|
export { errorResult, textResult } from "#agents/mcp-tools/common";
|
|
4
4
|
export { createPublishHtmlToolDefinitions } from "#agents/mcp-tools/publish-html";
|
|
5
|
+
export { createRequestVaultToolDefinition } from "#agents/mcp-tools/request-vault";
|
|
5
6
|
export type { SelfConfigContext } from "#agents/mcp-tools/self-config";
|
|
6
7
|
export { createSelfConfigToolDefinitions } from "#agents/mcp-tools/self-config";
|
|
7
8
|
export {
|
|
@@ -5,6 +5,11 @@ export {
|
|
|
5
5
|
startAskUserQuestionGateOwner,
|
|
6
6
|
stopAskUserQuestionGateOwner,
|
|
7
7
|
} from "#agents/mcp-tools/ask-user";
|
|
8
|
+
export {
|
|
9
|
+
reconcilePendingRequestVaultGates,
|
|
10
|
+
startRequestVaultGateOwner,
|
|
11
|
+
stopRequestVaultGateOwner,
|
|
12
|
+
} from "#agents/mcp-tools/request-vault";
|
|
8
13
|
export { sweepStaleSubagentCards } from "#agents/mcp-tools/spawn-subagent";
|
|
9
14
|
export {
|
|
10
15
|
RuntimeGatewayIdempotencyConflictError,
|
|
@@ -23,7 +23,7 @@ import { deliverPeerReply, type RemoteReplyRoute } from "#mcp/session-comm/peer-
|
|
|
23
23
|
import { type CoalescingRunner, createCoalescingRunner } from "#outbox/coalescing-runner";
|
|
24
24
|
import { deleteProcessingFile, drainOutboxFile, parseOutboxLine } from "#outbox/file-ops";
|
|
25
25
|
import { watchDir } from "#outbox/utils";
|
|
26
|
-
import {
|
|
26
|
+
import { SESSION_INBOX_DIR } from "#platform/config";
|
|
27
27
|
import { FROM_AUTO_CONTINUE, FROM_SELF_SCHEDULE } from "#platform/constants";
|
|
28
28
|
import { appendJsonlEntry, readJsonlLines } from "#platform/jsonl";
|
|
29
29
|
import { logger } from "#platform/logger";
|
|
@@ -123,16 +123,22 @@ export function normalizeAskTarget(target: unknown): "main" | "cron" | null {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
|
-
*
|
|
126
|
+
* Every ask now runs as a normal turn on the target's own session
|
|
127
127
|
* (tell_session-style, visible in the target) instead of on a fork. Codex
|
|
128
128
|
* scopes its prompt cache to the thread, so a fork's first request never
|
|
129
129
|
* reused the parent's cache and every ask paid full input price; running on
|
|
130
|
-
* the target session keeps the cache warm
|
|
131
|
-
*
|
|
132
|
-
*
|
|
130
|
+
* the target session keeps the cache warm.
|
|
131
|
+
*
|
|
132
|
+
* Cron asks (2026-10-20) dropped the fork too, trading it for
|
|
133
|
+
* `turnConcurrency: "isolated"` so the ask doesn't queue behind the topic's
|
|
134
|
+
* main visible turn. This does NOT serialize against a scheduled cron run
|
|
135
|
+
* landing on the identical session at the same moment — the fork used to
|
|
136
|
+
* sidestep that by mutating a private copy instead of the shared session.
|
|
137
|
+
* Treat a corrupted/interleaved cron rollout as a known residual risk until
|
|
138
|
+
* the cron scheduler itself gets a per-session lock.
|
|
133
139
|
*/
|
|
134
|
-
export function askRunsOnTargetSession(
|
|
135
|
-
return
|
|
140
|
+
export function askRunsOnTargetSession(_target: "main" | "cron"): boolean {
|
|
141
|
+
return true;
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
export function resolveAskParentSessionId(
|
|
@@ -1070,33 +1076,16 @@ async function handleAskEntry(
|
|
|
1070
1076
|
const { getTopic } = await import("#storage/api-topics");
|
|
1071
1077
|
const fullTopic = getTopic(topic.id);
|
|
1072
1078
|
const agentOverride = (fullTopic?.agent ?? topic.agent ?? undefined) as AgentKind | undefined;
|
|
1073
|
-
const cwd = resolveTopicWorkspaceDir(topic.id);
|
|
1074
1079
|
if (!agentOverride) {
|
|
1075
1080
|
clearPendingAskForEntry(scope, entry, topicName, "target-agent-missing");
|
|
1076
1081
|
await notifyAskDrop(scope, entry, topicName, "(error: target topic has no agent)");
|
|
1077
1082
|
return;
|
|
1078
1083
|
}
|
|
1079
1084
|
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
const { getRegistry, getRegistryOperations } = await import("#agents/registry");
|
|
1085
|
-
const { resolveModelForAgent } = await import("#agents/model-catalog");
|
|
1086
|
-
const { getApiTopicConfig } = await import("#storage/api-topic-config");
|
|
1087
|
-
const { readConversation } = await import("#storage/conversations");
|
|
1088
|
-
const registry = getRegistry(agentOverride);
|
|
1089
|
-
const topicConfig = getApiTopicConfig(topic.id);
|
|
1090
|
-
const model = resolveModelForAgent(
|
|
1091
|
-
agentOverride,
|
|
1092
|
-
topicConfig?.model ?? fullTopic?.defaultModel,
|
|
1093
|
-
registry,
|
|
1094
|
-
);
|
|
1095
|
-
const requestedEffort = topicConfig?.effort ?? fullTopic?.defaultEffort;
|
|
1096
|
-
const effort =
|
|
1097
|
-
requestedEffort && registry.validateEffort(requestedEffort)
|
|
1098
|
-
? requestedEffort
|
|
1099
|
-
: registry.defaultEffort;
|
|
1085
|
+
// Every ask runs as a normal turn on the target's own session (see
|
|
1086
|
+
// askRunsOnTargetSession above), so no fork/model/effort resolution is
|
|
1087
|
+
// needed here — triggerTopicAiTurn below resolves those itself, same as
|
|
1088
|
+
// a regular tell_session.
|
|
1100
1089
|
const cronSession =
|
|
1101
1090
|
askTarget === "cron" ? getRegisteredCronSession(topic.id, agentOverride) : null;
|
|
1102
1091
|
const parentSessionId =
|
|
@@ -1161,6 +1150,17 @@ async function handleAskEntry(
|
|
|
1161
1150
|
label: `Ask from ${fromLabel}`,
|
|
1162
1151
|
message: entry.message,
|
|
1163
1152
|
},
|
|
1153
|
+
// cron: target the cron session explicitly and run it isolated from
|
|
1154
|
+
// the topic's main visible turn (no fork — see askRunsOnTargetSession's
|
|
1155
|
+
// doc comment for the residual concurrency tradeoff). main: default to
|
|
1156
|
+
// the topic's own session/turn slot, same as tell_session.
|
|
1157
|
+
...(askTarget === "cron"
|
|
1158
|
+
? {
|
|
1159
|
+
sessionId: parentSessionId,
|
|
1160
|
+
sessionScope: "isolated" as const,
|
|
1161
|
+
turnConcurrency: "isolated" as const,
|
|
1162
|
+
}
|
|
1163
|
+
: {}),
|
|
1164
1164
|
onSettled,
|
|
1165
1165
|
onDispatched,
|
|
1166
1166
|
});
|
|
@@ -1171,149 +1171,11 @@ async function handleAskEntry(
|
|
|
1171
1171
|
return;
|
|
1172
1172
|
}
|
|
1173
1173
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
cwd,
|
|
1180
|
-
userId: scope.userId,
|
|
1181
|
-
topicName,
|
|
1182
|
-
title: `ask: ${entry.from} -> ${targetLabel}`,
|
|
1183
|
-
model,
|
|
1184
|
-
...(effort ? { effort } : {}),
|
|
1185
|
-
})
|
|
1186
|
-
: undefined;
|
|
1187
|
-
|
|
1188
|
-
let forkPlan: AskForkPlan;
|
|
1189
|
-
try {
|
|
1190
|
-
forkPlan = await createAskForkPlan({
|
|
1191
|
-
// Read once: queued/replayed asks must keep the history visible at inbox
|
|
1192
|
-
// consumption time even if the target topic advances while they wait.
|
|
1193
|
-
entries: askTarget === "cron" ? [] : readConversation(scope.userId, topicName),
|
|
1194
|
-
...(forkNative ? { forkNative } : {}),
|
|
1195
|
-
...(askTarget === "cron" && forkNative ? { prepareSession: forkNative } : {}),
|
|
1196
|
-
synthesize: async (entries) => {
|
|
1197
|
-
if (askTarget === "cron") {
|
|
1198
|
-
throw new Error("cron session fork failed");
|
|
1199
|
-
}
|
|
1200
|
-
const rollout = getRegistryOperations(agentOverride).writeRollout({
|
|
1201
|
-
cwd,
|
|
1202
|
-
entries,
|
|
1203
|
-
model,
|
|
1204
|
-
...(effort ? { effort } : {}),
|
|
1205
|
-
});
|
|
1206
|
-
return {
|
|
1207
|
-
agent: agentOverride,
|
|
1208
|
-
forkId: rollout.sessionId,
|
|
1209
|
-
rolloutPath: rollout.rolloutPath,
|
|
1210
|
-
};
|
|
1211
|
-
},
|
|
1212
|
-
onNativeForkError: (err) => {
|
|
1213
|
-
logger.warn(
|
|
1214
|
-
{ err, topicName, from: entry.from, requestId, parentSessionId },
|
|
1215
|
-
"session-inbox: native target fork failed",
|
|
1216
|
-
);
|
|
1217
|
-
},
|
|
1218
|
-
});
|
|
1219
|
-
} catch (err) {
|
|
1220
|
-
// This entry has already been claimed. Convert preparation failure into a
|
|
1221
|
-
// terminal ask result so processTopicInbox can delete its `.processing`
|
|
1222
|
-
// file instead of leaving the request undiscoverable until another write.
|
|
1223
|
-
logger.error(
|
|
1224
|
-
{ err, topicName, from: entry.from, requestId, parentSessionId },
|
|
1225
|
-
"session-inbox: failed to prepare target ask session",
|
|
1226
|
-
);
|
|
1227
|
-
try {
|
|
1228
|
-
clearPendingAskForEntry(scope, entry, topicName, "target-session-prepare-failed");
|
|
1229
|
-
} catch (clearError) {
|
|
1230
|
-
logger.warn(
|
|
1231
|
-
{ err: clearError, topicName, from: entry.from, requestId },
|
|
1232
|
-
"session-inbox: failed to clear ask after session preparation failure",
|
|
1233
|
-
);
|
|
1234
|
-
}
|
|
1235
|
-
const message = "(error: target session could not be prepared)";
|
|
1236
|
-
if (remoteReply) {
|
|
1237
|
-
try {
|
|
1238
|
-
const delivered = await deliverPeerReply(remoteReply, targetLabel, message, "error");
|
|
1239
|
-
if (!delivered) {
|
|
1240
|
-
logger.warn(
|
|
1241
|
-
{ topicName, from: entry.from, requestId },
|
|
1242
|
-
"session-inbox: failed to deliver remote ask preparation error",
|
|
1243
|
-
);
|
|
1244
|
-
}
|
|
1245
|
-
} catch (deliveryError) {
|
|
1246
|
-
logger.warn(
|
|
1247
|
-
{ err: deliveryError, topicName, from: entry.from, requestId },
|
|
1248
|
-
"session-inbox: remote ask preparation error delivery threw",
|
|
1249
|
-
);
|
|
1250
|
-
}
|
|
1251
|
-
} else {
|
|
1252
|
-
try {
|
|
1253
|
-
await notifyAskDrop(scope, entry, targetLabel, message);
|
|
1254
|
-
} catch (deliveryError) {
|
|
1255
|
-
logger.warn(
|
|
1256
|
-
{ err: deliveryError, topicName, from: entry.from, requestId },
|
|
1257
|
-
"session-inbox: local ask preparation error delivery threw",
|
|
1258
|
-
);
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
return;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
if (askTarget === "cron") {
|
|
1265
|
-
const currentCronSession = getRegisteredCronSession(topic.id, agentOverride);
|
|
1266
|
-
if (
|
|
1267
|
-
!cronSession ||
|
|
1268
|
-
!currentCronSession ||
|
|
1269
|
-
currentCronSession.sessionId !== cronSession.sessionId ||
|
|
1270
|
-
currentCronSession.ownerUserId !== cronSession.ownerUserId
|
|
1271
|
-
) {
|
|
1272
|
-
cleanupAgentFork(forkPlan.forkHandle);
|
|
1273
|
-
clearPendingAskForEntry(scope, entry, topicName, "cron-session-changed-during-fork");
|
|
1274
|
-
const message =
|
|
1275
|
-
"(error: cron session changed while preparing the request; retry the request)";
|
|
1276
|
-
if (remoteReply) {
|
|
1277
|
-
await deliverPeerReply(remoteReply, targetLabel, message, "error");
|
|
1278
|
-
} else {
|
|
1279
|
-
await notifyAskDrop(scope, entry, targetLabel, message);
|
|
1280
|
-
}
|
|
1281
|
-
return;
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
// Trigger the AI turn immediately with the pre-forked session.
|
|
1286
|
-
triggerTopicAiTurn(
|
|
1287
|
-
topic.id,
|
|
1288
|
-
String(scope.userId),
|
|
1289
|
-
prompt,
|
|
1290
|
-
(agentOverride ?? topic.agent ?? undefined) as Parameters<typeof triggerTopicAiTurn>[3],
|
|
1291
|
-
{
|
|
1292
|
-
origin: entry.from,
|
|
1293
|
-
requestId,
|
|
1294
|
-
contextId: entry.contextId,
|
|
1295
|
-
depth: (entry.fromDepth ?? 0) + 1,
|
|
1296
|
-
silent: true,
|
|
1297
|
-
sessionId: forkPlan.forkHandle.forkId,
|
|
1298
|
-
// The fork is read-only and session-isolated, so it does not need the
|
|
1299
|
-
// target topic's visible execution slot. Answer immediately even while
|
|
1300
|
-
// that topic is processing a user turn.
|
|
1301
|
-
turnConcurrency: "isolated",
|
|
1302
|
-
forkHandle: forkPlan.forkHandle,
|
|
1303
|
-
// Keep a regeneration recipe even though the first fork is eager. The
|
|
1304
|
-
// isolated room prevents user preemption; the recipe remains available
|
|
1305
|
-
// for a clean retry if the provider reports an expired session.
|
|
1306
|
-
prepareSession: forkPlan.prepareSession,
|
|
1307
|
-
cwd,
|
|
1308
|
-
onSettled,
|
|
1309
|
-
onDispatched,
|
|
1310
|
-
},
|
|
1311
|
-
);
|
|
1312
|
-
|
|
1313
|
-
logger.info(
|
|
1314
|
-
{ topicName, from: entry.from, requestId, callerTopicId },
|
|
1315
|
-
"session-inbox: ask delivered",
|
|
1316
|
-
);
|
|
1174
|
+
// Every ask now runs on the target's own session (see
|
|
1175
|
+
// askRunsOnTargetSession above) — the fork-and-copy path this used to
|
|
1176
|
+
// fall through to for ask_cron was removed 2026-10-20. createAskForkPlan
|
|
1177
|
+
// itself is kept (still exported + unit-tested) in case a future caller
|
|
1178
|
+
// needs a true read-only copy again.
|
|
1317
1179
|
}
|
|
1318
1180
|
|
|
1319
1181
|
// ── Bootstrap ───────────────────────────────────────────────────────
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
scheduleIdleCompactForTopic,
|
|
10
10
|
} from "#agents/idle-compact";
|
|
11
11
|
import { cancelPendingAskUserQuestions } from "#agents/mcp-tools/ask-user";
|
|
12
|
+
import { cancelPendingVaultSecretRequests } from "#agents/mcp-tools/request-vault";
|
|
12
13
|
import {
|
|
13
14
|
settleSubagentFailure,
|
|
14
15
|
settleSubagentSuccess,
|
|
@@ -935,6 +936,7 @@ export async function runTurnEventStream(
|
|
|
935
936
|
const stillCurrent = getRoomQuery(roomId)?.queryId === queryId;
|
|
936
937
|
clearRoomQuery(roomId, queryId);
|
|
937
938
|
cancelPendingAskUserQuestions(topicId, queryId);
|
|
939
|
+
cancelPendingVaultSecretRequests(topicId, queryId);
|
|
938
940
|
if (stillCurrent && roomId === topicId) clearQueryState(userId, topicId, topicTitle);
|
|
939
941
|
// Stop typing only if this turn still owns the room. A superseded turn may
|
|
940
942
|
// finish cleanup after its replacement already broadcast a fresh start.
|
|
@@ -80,6 +80,9 @@ function initializeApiMessagesSchema(): void {
|
|
|
80
80
|
try {
|
|
81
81
|
db.exec("ALTER TABLE api_messages ADD COLUMN subagent_card TEXT");
|
|
82
82
|
} catch {}
|
|
83
|
+
try {
|
|
84
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN vault_secret_request TEXT");
|
|
85
|
+
} catch {}
|
|
83
86
|
// Index on topic_id; SQLite appends the implicit rowid to every index entry,
|
|
84
87
|
// so `WHERE topic_id=? AND rowid>? ORDER BY rowid` stays index-driven.
|
|
85
88
|
db.exec("CREATE INDEX IF NOT EXISTS idx_api_messages_topic ON api_messages(topic_id)");
|
|
@@ -111,6 +114,7 @@ export interface ApiMessageRow {
|
|
|
111
114
|
ask_user_question: string | null;
|
|
112
115
|
tell_card: string | null;
|
|
113
116
|
subagent_card: string | null;
|
|
117
|
+
vault_secret_request: string | null;
|
|
114
118
|
mentions: string | null;
|
|
115
119
|
thread_root_id: string | null;
|
|
116
120
|
created_at: string;
|
|
@@ -166,6 +170,7 @@ function rowToDto(r: ApiMessageRow): MessageDto {
|
|
|
166
170
|
askUserQuestion: r.ask_user_question ? JSON.parse(r.ask_user_question) : undefined,
|
|
167
171
|
tellCard: r.tell_card ? JSON.parse(r.tell_card) : undefined,
|
|
168
172
|
subagentCard: r.subagent_card ? JSON.parse(r.subagent_card) : undefined,
|
|
173
|
+
vaultSecretRequest: r.vault_secret_request ? JSON.parse(r.vault_secret_request) : undefined,
|
|
169
174
|
mentions: r.mentions ? JSON.parse(r.mentions) : undefined,
|
|
170
175
|
threadRootId: r.thread_root_id ?? undefined,
|
|
171
176
|
createdAt: r.created_at,
|
|
@@ -200,8 +205,8 @@ export function appendApiMessage(msg: MessageDto, options: AppendApiMessageOptio
|
|
|
200
205
|
const result = db
|
|
201
206
|
.query(
|
|
202
207
|
`INSERT INTO api_messages
|
|
203
|
-
(id, topic_id, parent_id, author_id, source_adapter, source_node, source_message_id, text, query_id, agent_type, model, attachments, usage, deleted, edited_at, reactions, kind, ask_user_question, tell_card, subagent_card, mentions, thread_root_id, created_at)
|
|
204
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
208
|
+
(id, topic_id, parent_id, author_id, source_adapter, source_node, source_message_id, text, query_id, agent_type, model, attachments, usage, deleted, edited_at, reactions, kind, ask_user_question, tell_card, subagent_card, vault_secret_request, mentions, thread_root_id, created_at)
|
|
209
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
205
210
|
ON CONFLICT(id) DO NOTHING`,
|
|
206
211
|
)
|
|
207
212
|
.run(
|
|
@@ -225,6 +230,7 @@ export function appendApiMessage(msg: MessageDto, options: AppendApiMessageOptio
|
|
|
225
230
|
msg.askUserQuestion ? JSON.stringify(msg.askUserQuestion) : null,
|
|
226
231
|
msg.tellCard ? JSON.stringify(msg.tellCard) : null,
|
|
227
232
|
msg.subagentCard ? JSON.stringify(msg.subagentCard) : null,
|
|
233
|
+
msg.vaultSecretRequest ? JSON.stringify(msg.vaultSecretRequest) : null,
|
|
228
234
|
msg.mentions?.length ? JSON.stringify(msg.mentions) : null,
|
|
229
235
|
msg.threadRootId ?? null,
|
|
230
236
|
msg.createdAt,
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { db } from "#storage/forum-db";
|
|
2
|
+
import { registerStorageSchemaInitializer } from "#storage/storage-host";
|
|
3
|
+
import type { VaultSecretRequestDto } from "#types/api";
|
|
4
|
+
|
|
5
|
+
export type VaultRequestGateState =
|
|
6
|
+
| "pending"
|
|
7
|
+
| "claimed"
|
|
8
|
+
| "answered"
|
|
9
|
+
| "cancelled"
|
|
10
|
+
| "quarantined";
|
|
11
|
+
|
|
12
|
+
export interface VaultRequestGateRecord {
|
|
13
|
+
gateId: string;
|
|
14
|
+
topicId: string;
|
|
15
|
+
queryId?: string;
|
|
16
|
+
idempotencyKey: string;
|
|
17
|
+
bodyHash: string;
|
|
18
|
+
messageId: string;
|
|
19
|
+
ownerId: string;
|
|
20
|
+
state: VaultRequestGateState;
|
|
21
|
+
outcome?: "fulfilled" | "declined";
|
|
22
|
+
answeredBy?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface VaultRequestGateRow {
|
|
26
|
+
gate_id: string;
|
|
27
|
+
topic_id: string;
|
|
28
|
+
query_id: string | null;
|
|
29
|
+
idempotency_key: string;
|
|
30
|
+
body_hash: string;
|
|
31
|
+
message_id: string;
|
|
32
|
+
owner_id: string;
|
|
33
|
+
state: VaultRequestGateState;
|
|
34
|
+
outcome: "fulfilled" | "declined" | null;
|
|
35
|
+
answered_by: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface VaultRequestGateCardUpdate {
|
|
39
|
+
topicId: string;
|
|
40
|
+
messageId: string;
|
|
41
|
+
vaultSecretRequest: VaultSecretRequestDto;
|
|
42
|
+
editedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function initializeVaultRequestGateSchema(): void {
|
|
46
|
+
db.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS vault_request_gates (
|
|
48
|
+
gate_id TEXT PRIMARY KEY,
|
|
49
|
+
topic_id TEXT NOT NULL REFERENCES api_topics(id) ON DELETE CASCADE,
|
|
50
|
+
query_id TEXT,
|
|
51
|
+
idempotency_key TEXT NOT NULL,
|
|
52
|
+
body_hash TEXT NOT NULL,
|
|
53
|
+
message_id TEXT NOT NULL UNIQUE,
|
|
54
|
+
owner_id TEXT NOT NULL,
|
|
55
|
+
state TEXT NOT NULL,
|
|
56
|
+
outcome TEXT,
|
|
57
|
+
answered_by TEXT,
|
|
58
|
+
claim_source TEXT,
|
|
59
|
+
created_at TEXT NOT NULL,
|
|
60
|
+
updated_at TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_vault_request_gates_idempotency
|
|
63
|
+
ON vault_request_gates(topic_id, idempotency_key, created_at);
|
|
64
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_request_gates_active_key
|
|
65
|
+
ON vault_request_gates(topic_id, idempotency_key)
|
|
66
|
+
WHERE state IN ('pending', 'claimed');
|
|
67
|
+
`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
registerStorageSchemaInitializer(initializeVaultRequestGateSchema, 32);
|
|
71
|
+
|
|
72
|
+
function fromRow(row: VaultRequestGateRow): VaultRequestGateRecord {
|
|
73
|
+
return {
|
|
74
|
+
gateId: row.gate_id,
|
|
75
|
+
topicId: row.topic_id,
|
|
76
|
+
queryId: row.query_id ?? undefined,
|
|
77
|
+
idempotencyKey: row.idempotency_key,
|
|
78
|
+
bodyHash: row.body_hash,
|
|
79
|
+
messageId: row.message_id,
|
|
80
|
+
ownerId: row.owner_id,
|
|
81
|
+
state: row.state,
|
|
82
|
+
outcome: row.outcome ?? undefined,
|
|
83
|
+
answeredBy: row.answered_by ?? undefined,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function latestGate(topicId: string, idempotencyKey: string): VaultRequestGateRecord | null {
|
|
88
|
+
const row = db
|
|
89
|
+
.query(
|
|
90
|
+
`SELECT * FROM vault_request_gates
|
|
91
|
+
WHERE topic_id = ? AND idempotency_key = ?
|
|
92
|
+
ORDER BY rowid DESC LIMIT 1`,
|
|
93
|
+
)
|
|
94
|
+
.get(topicId, idempotencyKey) as VaultRequestGateRow | undefined;
|
|
95
|
+
return row ? fromRow(row) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function expireVaultCard(
|
|
99
|
+
topicId: string,
|
|
100
|
+
messageId: string,
|
|
101
|
+
editedAt: string,
|
|
102
|
+
): VaultRequestGateCardUpdate | null {
|
|
103
|
+
const row = db
|
|
104
|
+
.query(
|
|
105
|
+
`SELECT vault_secret_request FROM api_messages
|
|
106
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0 AND kind = 'vault_secret_request'`,
|
|
107
|
+
)
|
|
108
|
+
.get(topicId, messageId) as { vault_secret_request: string | null } | undefined;
|
|
109
|
+
if (!row?.vault_secret_request) return null;
|
|
110
|
+
const request = JSON.parse(row.vault_secret_request) as VaultSecretRequestDto;
|
|
111
|
+
if (request.status !== "pending") return null;
|
|
112
|
+
const expired: VaultSecretRequestDto = { ...request, status: "expired" };
|
|
113
|
+
db.query(
|
|
114
|
+
`UPDATE api_messages SET vault_secret_request = ?, edited_at = ?
|
|
115
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0 AND kind = 'vault_secret_request'`,
|
|
116
|
+
).run(JSON.stringify(expired), editedAt, topicId, messageId);
|
|
117
|
+
return { topicId, messageId, vaultSecretRequest: expired, editedAt };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type PrepareVaultRequestGateResult =
|
|
121
|
+
| { outcome: "created"; gate: VaultRequestGateRecord }
|
|
122
|
+
| { outcome: "pending"; gate: VaultRequestGateRecord }
|
|
123
|
+
| { outcome: "replay"; gate: VaultRequestGateRecord }
|
|
124
|
+
| { outcome: "conflict"; gate: VaultRequestGateRecord };
|
|
125
|
+
|
|
126
|
+
export function prepareVaultRequestGate(args: {
|
|
127
|
+
gateId: string;
|
|
128
|
+
topicId: string;
|
|
129
|
+
queryId?: string;
|
|
130
|
+
idempotencyKey: string;
|
|
131
|
+
bodyHash: string;
|
|
132
|
+
messageId: string;
|
|
133
|
+
ownerId: string;
|
|
134
|
+
now: string;
|
|
135
|
+
}): PrepareVaultRequestGateResult {
|
|
136
|
+
return db
|
|
137
|
+
.transaction(() => {
|
|
138
|
+
const latest = latestGate(args.topicId, args.idempotencyKey);
|
|
139
|
+
if (latest?.bodyHash !== undefined && latest.bodyHash !== args.bodyHash) {
|
|
140
|
+
return { outcome: "conflict", gate: latest } as const;
|
|
141
|
+
}
|
|
142
|
+
if (latest?.state === "answered") return { outcome: "replay", gate: latest } as const;
|
|
143
|
+
if (latest && (latest.state === "pending" || latest.state === "claimed")) {
|
|
144
|
+
return { outcome: "pending", gate: latest } as const;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
db.query(
|
|
148
|
+
`INSERT INTO vault_request_gates
|
|
149
|
+
(gate_id, topic_id, query_id, idempotency_key, body_hash, message_id, owner_id, state, created_at, updated_at)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
|
|
151
|
+
).run(
|
|
152
|
+
args.gateId,
|
|
153
|
+
args.topicId,
|
|
154
|
+
args.queryId ?? null,
|
|
155
|
+
args.idempotencyKey,
|
|
156
|
+
args.bodyHash,
|
|
157
|
+
args.messageId,
|
|
158
|
+
args.ownerId,
|
|
159
|
+
args.now,
|
|
160
|
+
args.now,
|
|
161
|
+
);
|
|
162
|
+
return {
|
|
163
|
+
outcome: "created",
|
|
164
|
+
gate: {
|
|
165
|
+
gateId: args.gateId,
|
|
166
|
+
topicId: args.topicId,
|
|
167
|
+
queryId: args.queryId,
|
|
168
|
+
idempotencyKey: args.idempotencyKey,
|
|
169
|
+
bodyHash: args.bodyHash,
|
|
170
|
+
messageId: args.messageId,
|
|
171
|
+
ownerId: args.ownerId,
|
|
172
|
+
state: "pending",
|
|
173
|
+
},
|
|
174
|
+
} as const;
|
|
175
|
+
})
|
|
176
|
+
.immediate();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function quarantineVaultRequestGate(
|
|
180
|
+
gateId: string,
|
|
181
|
+
ownerId: string,
|
|
182
|
+
now = new Date().toISOString(),
|
|
183
|
+
): VaultRequestGateCardUpdate | null {
|
|
184
|
+
return db
|
|
185
|
+
.transaction(() => {
|
|
186
|
+
const row = db.query("SELECT * FROM vault_request_gates WHERE gate_id = ?").get(gateId) as
|
|
187
|
+
| VaultRequestGateRow
|
|
188
|
+
| undefined;
|
|
189
|
+
if (!row || row.owner_id !== ownerId || !["pending", "claimed"].includes(row.state)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const result = db
|
|
193
|
+
.query(
|
|
194
|
+
`UPDATE vault_request_gates SET state = 'quarantined', updated_at = ?
|
|
195
|
+
WHERE gate_id = ? AND owner_id = ? AND state IN ('pending', 'claimed')`,
|
|
196
|
+
)
|
|
197
|
+
.run(now, gateId, ownerId);
|
|
198
|
+
if (Number(result.changes ?? 0) === 0) return null;
|
|
199
|
+
return expireVaultCard(row.topic_id, row.message_id, now);
|
|
200
|
+
})
|
|
201
|
+
.immediate();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function quarantineForeignVaultRequestGates(
|
|
205
|
+
liveOwnerIds: ReadonlySet<string>,
|
|
206
|
+
now = new Date().toISOString(),
|
|
207
|
+
): VaultRequestGateCardUpdate[] {
|
|
208
|
+
return db
|
|
209
|
+
.transaction(() => {
|
|
210
|
+
const rows = db
|
|
211
|
+
.query(
|
|
212
|
+
`SELECT * FROM vault_request_gates
|
|
213
|
+
WHERE state IN ('pending', 'claimed')`,
|
|
214
|
+
)
|
|
215
|
+
.all() as VaultRequestGateRow[];
|
|
216
|
+
const updates: VaultRequestGateCardUpdate[] = [];
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
if (liveOwnerIds.has(row.owner_id)) continue;
|
|
219
|
+
if (row.state === "claimed" && row.outcome && row.answered_by) {
|
|
220
|
+
db.query(
|
|
221
|
+
`UPDATE vault_request_gates SET state = 'answered', updated_at = ?
|
|
222
|
+
WHERE gate_id = ? AND state = 'claimed'`,
|
|
223
|
+
).run(now, row.gate_id);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const result = db
|
|
227
|
+
.query(
|
|
228
|
+
`UPDATE vault_request_gates SET state = 'quarantined', updated_at = ?
|
|
229
|
+
WHERE gate_id = ? AND state IN ('pending', 'claimed')`,
|
|
230
|
+
)
|
|
231
|
+
.run(now, row.gate_id);
|
|
232
|
+
if (Number(result.changes ?? 0) === 0) continue;
|
|
233
|
+
const update = expireVaultCard(row.topic_id, row.message_id, now);
|
|
234
|
+
if (update) updates.push(update);
|
|
235
|
+
}
|
|
236
|
+
const legacyCards = db
|
|
237
|
+
.query(
|
|
238
|
+
`SELECT m.topic_id, m.id
|
|
239
|
+
FROM api_messages m
|
|
240
|
+
LEFT JOIN vault_request_gates g ON g.message_id = m.id
|
|
241
|
+
WHERE m.kind = 'vault_secret_request'
|
|
242
|
+
AND m.deleted = 0
|
|
243
|
+
AND g.gate_id IS NULL`,
|
|
244
|
+
)
|
|
245
|
+
.all() as { topic_id: string; id: string }[];
|
|
246
|
+
for (const card of legacyCards) {
|
|
247
|
+
const update = expireVaultCard(card.topic_id, card.id, now);
|
|
248
|
+
if (update) updates.push(update);
|
|
249
|
+
}
|
|
250
|
+
return updates;
|
|
251
|
+
})
|
|
252
|
+
.immediate();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export type ClaimVaultRequestGateResult =
|
|
256
|
+
| {
|
|
257
|
+
outcome: "claimed";
|
|
258
|
+
gate: VaultRequestGateRecord;
|
|
259
|
+
vaultSecretRequest: VaultSecretRequestDto;
|
|
260
|
+
editedAt: string;
|
|
261
|
+
}
|
|
262
|
+
| { outcome: "unavailable" };
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Claim the gate and mark the card's outcome. This never touches the secret
|
|
266
|
+
* value itself — the caller is responsible for writing to the vault (via
|
|
267
|
+
* `vaultSet`) *before* calling this, so the card only ever reflects
|
|
268
|
+
* "fulfilled"/"declined", never the plaintext.
|
|
269
|
+
*/
|
|
270
|
+
export function claimVaultRequestGateAndAnswer(args: {
|
|
271
|
+
topicId: string;
|
|
272
|
+
messageId: string;
|
|
273
|
+
outcome: "fulfilled" | "declined";
|
|
274
|
+
userId: string;
|
|
275
|
+
ownerId: string;
|
|
276
|
+
source: string;
|
|
277
|
+
now: string;
|
|
278
|
+
}): ClaimVaultRequestGateResult {
|
|
279
|
+
return db
|
|
280
|
+
.transaction(() => {
|
|
281
|
+
const row = db
|
|
282
|
+
.query(
|
|
283
|
+
`SELECT * FROM vault_request_gates
|
|
284
|
+
WHERE topic_id = ? AND message_id = ?
|
|
285
|
+
ORDER BY rowid DESC LIMIT 1`,
|
|
286
|
+
)
|
|
287
|
+
.get(args.topicId, args.messageId) as VaultRequestGateRow | undefined;
|
|
288
|
+
if (!row || row.owner_id !== args.ownerId || row.state !== "pending") {
|
|
289
|
+
return { outcome: "unavailable" } as const;
|
|
290
|
+
}
|
|
291
|
+
const message = db
|
|
292
|
+
.query(
|
|
293
|
+
`SELECT vault_secret_request FROM api_messages
|
|
294
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0 AND kind = 'vault_secret_request'`,
|
|
295
|
+
)
|
|
296
|
+
.get(args.topicId, args.messageId) as { vault_secret_request: string | null } | undefined;
|
|
297
|
+
if (!message?.vault_secret_request) return { outcome: "unavailable" } as const;
|
|
298
|
+
const request = JSON.parse(message.vault_secret_request) as VaultSecretRequestDto;
|
|
299
|
+
if (request.status !== "pending") return { outcome: "unavailable" } as const;
|
|
300
|
+
|
|
301
|
+
const claimed = db
|
|
302
|
+
.query(
|
|
303
|
+
`UPDATE vault_request_gates
|
|
304
|
+
SET state = 'answered', outcome = ?, answered_by = ?, claim_source = ?, updated_at = ?
|
|
305
|
+
WHERE gate_id = ? AND owner_id = ? AND state = 'pending'`,
|
|
306
|
+
)
|
|
307
|
+
.run(args.outcome, args.userId, args.source, args.now, row.gate_id, args.ownerId);
|
|
308
|
+
if (Number(claimed.changes ?? 0) === 0) return { outcome: "unavailable" } as const;
|
|
309
|
+
|
|
310
|
+
const answered: VaultSecretRequestDto = {
|
|
311
|
+
...request,
|
|
312
|
+
status: args.outcome,
|
|
313
|
+
answeredBy: args.userId,
|
|
314
|
+
};
|
|
315
|
+
db.query(
|
|
316
|
+
`UPDATE api_messages SET vault_secret_request = ?, edited_at = ?
|
|
317
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0 AND kind = 'vault_secret_request'`,
|
|
318
|
+
).run(JSON.stringify(answered), args.now, args.topicId, args.messageId);
|
|
319
|
+
return {
|
|
320
|
+
outcome: "claimed",
|
|
321
|
+
gate: fromRow({
|
|
322
|
+
...row,
|
|
323
|
+
state: "answered",
|
|
324
|
+
outcome: args.outcome,
|
|
325
|
+
answered_by: args.userId,
|
|
326
|
+
}),
|
|
327
|
+
vaultSecretRequest: answered,
|
|
328
|
+
editedAt: args.now,
|
|
329
|
+
} as const;
|
|
330
|
+
})
|
|
331
|
+
.immediate();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function cancelVaultRequestGate(
|
|
335
|
+
topicId: string,
|
|
336
|
+
messageId: string,
|
|
337
|
+
ownerId: string,
|
|
338
|
+
now = new Date().toISOString(),
|
|
339
|
+
): VaultRequestGateCardUpdate | null {
|
|
340
|
+
return db
|
|
341
|
+
.transaction(() => {
|
|
342
|
+
const row = db
|
|
343
|
+
.query(
|
|
344
|
+
`SELECT * FROM vault_request_gates
|
|
345
|
+
WHERE topic_id = ? AND message_id = ? ORDER BY rowid DESC LIMIT 1`,
|
|
346
|
+
)
|
|
347
|
+
.get(topicId, messageId) as VaultRequestGateRow | undefined;
|
|
348
|
+
if (!row || row.owner_id !== ownerId || row.state !== "pending") return null;
|
|
349
|
+
const result = db
|
|
350
|
+
.query(
|
|
351
|
+
`UPDATE vault_request_gates SET state = 'cancelled', updated_at = ?
|
|
352
|
+
WHERE gate_id = ? AND owner_id = ? AND state = 'pending'`,
|
|
353
|
+
)
|
|
354
|
+
.run(now, row.gate_id, ownerId);
|
|
355
|
+
if (Number(result.changes ?? 0) === 0) return null;
|
|
356
|
+
return expireVaultCard(topicId, messageId, now);
|
|
357
|
+
})
|
|
358
|
+
.immediate();
|
|
359
|
+
}
|