agents-city 0.3.0 → 0.5.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/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +294 -16
- package/README.md +291 -16
- package/benchmarks/latency/fake-claude-cli.mjs +2 -1
- package/benchmarks/reception/README.md +16 -0
- package/benchmarks/reception/run.mjs +84 -0
- package/bin/agents-city.js +5 -0
- package/bin/connect +5 -0
- package/bin/hall.html +208 -22
- package/bin/navegador.mjs +203 -13
- package/bin/serve.py +176 -111
- package/bin/test +18 -4
- package/bin/test-arnes.py +192 -0
- package/bin/test-cage.py +9 -0
- package/bin/test-channel.py +178 -12
- package/bin/test-claude-runtime.py +132 -4
- package/bin/test-connect-client.mjs +609 -0
- package/bin/test-connect.py +52 -0
- package/bin/test-contracts.py +214 -0
- package/bin/test-demo.py +67 -0
- package/bin/test-desinstala.py +170 -0
- package/bin/test-doctor.py +31 -0
- package/bin/test-i18n.py +189 -0
- package/bin/test-navegador.py +54 -0
- package/bin/test-seat.py +78 -20
- package/bin/test-security.py +2 -0
- package/bin/test-serve.py +414 -2
- package/bin/uninstall +5 -0
- package/city/web/dist/city.js +65 -65
- package/city/web/dist/index.html +18 -5
- package/city/web/dist-hall/hall.js +1566 -486
- package/city/web/index.html +17 -4
- package/city/web/src/bienvenida.ts +48 -341
- package/city/web/src/casa.ts +324 -0
- package/city/web/src/demo.ts +277 -0
- package/city/web/src/dialogo.ts +178 -0
- package/city/web/src/es.ts +281 -25
- package/city/web/src/explorador.ts +216 -0
- package/city/web/src/hall.ts +612 -139
- package/city/web/src/idioma.ts +21 -1
- package/city/web/src/main.ts +28 -5
- package/city/web/src/motores.ts +48 -1
- package/city/web/src/vista.ts +58 -0
- package/demo/graba.py +127 -0
- package/demo/grabaciones/legal.jsonl +22 -0
- package/demo/grabaciones/medicina.jsonl +22 -0
- package/demo/grabaciones/software.jsonl +22 -0
- package/docs/managed-connect.md +237 -0
- package/docs/security.md +41 -2
- package/package.json +8 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/adapter-prompts.ts +10 -0
- package/plugin/channel/adapter.js +78 -31
- package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
- package/plugin/channel/bus.js +109 -65
- package/plugin/channel/bus.ts +11 -1
- package/plugin/channel/client.js +67 -30
- package/plugin/channel/delivery-queue.ts +160 -21
- package/plugin/channel/hub/remote-roads.ts +32 -1
- package/plugin/channel/hub/road-controller.ts +116 -16
- package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
- package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
- package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
- package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
- package/plugin/channel/licenses/keyring-MIT.txt +21 -0
- package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
- package/plugin/channel/local-hub.js +2080 -97
- package/plugin/channel/local-hub.ts +5 -4
- package/plugin/channel/managed-connect/bridge.ts +208 -0
- package/plugin/channel/managed-connect/cli.ts +416 -0
- package/plugin/channel/managed-connect/device.ts +15 -0
- package/plugin/channel/managed-connect/local-cities.ts +94 -0
- package/plugin/channel/managed-connect/person-message.ts +74 -0
- package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
- package/plugin/channel/managed-connect/relay-session.ts +7 -0
- package/plugin/channel/managed-connect/storage.ts +653 -0
- package/plugin/channel/managed-connect/transport.ts +87 -0
- package/plugin/channel/managed-connect-cli.js +4777 -0
- package/plugin/channel/managed-connect-cli.ts +7 -0
- package/plugin/channel/managed-connect-client.d.ts +261 -0
- package/plugin/channel/managed-connect-client.js +6572 -0
- package/plugin/channel/managed-connect-client.manifest.json +38 -0
- package/plugin/channel/package-lock.json +123 -105
- package/plugin/channel/package.json +4 -4
- package/plugin/channel/protocol.ts +4 -0
- package/plugin/channel/reception.ts +896 -0
- package/plugin/channel/road-cli.ts +1 -1
- package/plugin/channel/runtime/arnes.json +189 -0
- package/plugin/channel/runtime/arnes.ts +47 -0
- package/plugin/channel/runtime/claude.ts +18 -0
- package/plugin/channel/runtime/codex-config.ts +51 -1
- package/plugin/channel/runtime/codex.ts +31 -11
- package/plugin/channel/runtime/kimi.ts +6 -4
- package/plugin/channel/runtime-files.ts +39 -5
- package/plugin/channel/runtime-gateway.js +426 -100
- package/plugin/channel/runtime-gateway.ts +12 -0
- package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
- package/plugin/scripts/arnes.py +271 -0
- package/plugin/scripts/busca.py +46 -10
- package/plugin/scripts/cage.py +5 -0
- package/plugin/scripts/city-session.sh +144 -28
- package/plugin/scripts/crecimiento.py +4 -1
- package/plugin/scripts/demos.py +122 -0
- package/plugin/scripts/desinstala.py +237 -0
- package/plugin/scripts/doctor.py +37 -15
- package/plugin/scripts/read-card.py +48 -8
- package/plugin/scripts/reception.py +665 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner-level reception boundary for messages that arrive from another
|
|
3
|
+
* machine.
|
|
4
|
+
*
|
|
5
|
+
* Remote ciphertext is decrypted by the local Connect client, then lands in
|
|
6
|
+
* this SQLite quarantine. It does not enter a city runtime, wake a model, or
|
|
7
|
+
* become available through `road.inbox` until the owner routes it in the Hall.
|
|
8
|
+
* Several city processes and the Python Hall may share the database, hence WAL,
|
|
9
|
+
* an immediate transaction for capacity checks, and conditional route claims.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { join, resolve } from 'node:path';
|
|
15
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
16
|
+
import type { CityContext } from './city-config.js';
|
|
17
|
+
import { markRoadInboxAccepted, recordRoadInbox } from './delivery-queue.js';
|
|
18
|
+
import { BUS_PROTOCOL, MAX_BODY, type BusEnvelope } from './protocol.js';
|
|
19
|
+
import { wrapUntrusted } from './untrusted.js';
|
|
20
|
+
|
|
21
|
+
export const RECEPTION_PROTOCOL = 'agents-city-reception/1' as const;
|
|
22
|
+
export const RECEPTION_SCHEMA_VERSION = 3;
|
|
23
|
+
export const AUTO_ROUTER_PROFILE = 'deterministic-rules/1' as const;
|
|
24
|
+
const DEFAULT_PENDING_MESSAGES = 10_000;
|
|
25
|
+
const MAX_PENDING_MESSAGES = 100_000;
|
|
26
|
+
const DEFAULT_PENDING_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
const MAX_PENDING_BYTES = 512 * 1024 * 1024;
|
|
28
|
+
const DELIVERY_BATCH = 20;
|
|
29
|
+
|
|
30
|
+
type ReceptionDatabase = DatabaseSync;
|
|
31
|
+
|
|
32
|
+
type ReceptionContext = Pick<CityContext, 'appHome'> & {
|
|
33
|
+
city: Pick<CityContext['city'], 'id' | 'address'>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ReceptionConnectionInput = {
|
|
37
|
+
roadId: string;
|
|
38
|
+
connectionId: string;
|
|
39
|
+
peerName: string;
|
|
40
|
+
peerEndpoint: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ReceptionOutboxRecord = {
|
|
44
|
+
messageId: string;
|
|
45
|
+
roadId: string;
|
|
46
|
+
connectionId: string;
|
|
47
|
+
kind: 'message' | 'rejection';
|
|
48
|
+
body: string;
|
|
49
|
+
inReplyTo: string | null;
|
|
50
|
+
attemptCount: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type PendingRoute = {
|
|
54
|
+
message_id: string;
|
|
55
|
+
source_city: string;
|
|
56
|
+
source_created_at: string;
|
|
57
|
+
body: string;
|
|
58
|
+
connection_id: string | null;
|
|
59
|
+
road_id: string | null;
|
|
60
|
+
approved_at: string;
|
|
61
|
+
approved_by: 'human' | 'auto';
|
|
62
|
+
attempt_count: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface ReceptionRecordResult {
|
|
66
|
+
inserted: boolean;
|
|
67
|
+
pending: number;
|
|
68
|
+
pendingBytes: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ReceptionDeliveryResult {
|
|
72
|
+
delivered: number;
|
|
73
|
+
failed: number;
|
|
74
|
+
remaining: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const databases = new Map<string, ReceptionDatabase>();
|
|
78
|
+
|
|
79
|
+
export function receptionDatabasePath(appHome: string): string {
|
|
80
|
+
return join(resolve(appHome), '.runtime', 'reception', 'reception.sqlite3');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Persist one decrypted remote message at the human boundary. Duplicate relay
|
|
85
|
+
* delivery is idempotent by message id. The function returns only after SQLite
|
|
86
|
+
* has committed, so the Connect session may safely ACK the relay afterwards.
|
|
87
|
+
*/
|
|
88
|
+
export function recordReceptionMessage(
|
|
89
|
+
context: ReceptionContext,
|
|
90
|
+
envelope: BusEnvelope,
|
|
91
|
+
): ReceptionRecordResult {
|
|
92
|
+
return recordReceptionMessages(context, [envelope]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Commit one relay frame in one transaction (up to protocol v4's 32 items). */
|
|
96
|
+
export function recordReceptionMessages(
|
|
97
|
+
context: ReceptionContext,
|
|
98
|
+
envelopes: BusEnvelope[],
|
|
99
|
+
): ReceptionRecordResult {
|
|
100
|
+
if (!envelopes.length || envelopes.length > 32) {
|
|
101
|
+
throw new Error('invalid_reception_message_batch');
|
|
102
|
+
}
|
|
103
|
+
const rows = envelopes.map(validateReceptionEnvelope);
|
|
104
|
+
if (new Set(rows.map((row) => row.envelope.id)).size !== rows.length) {
|
|
105
|
+
throw new Error('duplicate_reception_message_batch');
|
|
106
|
+
}
|
|
107
|
+
const database = receptionDatabase(context.appHome);
|
|
108
|
+
const maximumMessages = boundedInteger(
|
|
109
|
+
process.env.CITY_RECEPTION_MAX_PENDING,
|
|
110
|
+
DEFAULT_PENDING_MESSAGES,
|
|
111
|
+
100,
|
|
112
|
+
MAX_PENDING_MESSAGES,
|
|
113
|
+
);
|
|
114
|
+
const maximumBytes = boundedInteger(
|
|
115
|
+
process.env.CITY_RECEPTION_MAX_BYTES,
|
|
116
|
+
DEFAULT_PENDING_BYTES,
|
|
117
|
+
1024 * 1024,
|
|
118
|
+
MAX_PENDING_BYTES,
|
|
119
|
+
);
|
|
120
|
+
let committed = false;
|
|
121
|
+
try {
|
|
122
|
+
database.exec('BEGIN IMMEDIATE');
|
|
123
|
+
expireOldReception(database);
|
|
124
|
+
const fresh = rows.filter((row) => {
|
|
125
|
+
const existing = database
|
|
126
|
+
.prepare('SELECT 1 AS found FROM reception_messages WHERE message_id = ? LIMIT 1')
|
|
127
|
+
.get(row.envelope.id) as { found: number } | undefined;
|
|
128
|
+
return !existing;
|
|
129
|
+
});
|
|
130
|
+
if (!fresh.length) {
|
|
131
|
+
const current = receptionCounters(database);
|
|
132
|
+
database.exec('COMMIT');
|
|
133
|
+
committed = true;
|
|
134
|
+
return { inserted: false, ...current };
|
|
135
|
+
}
|
|
136
|
+
const counters = receptionCounters(database);
|
|
137
|
+
const bytes = fresh.reduce((sum, row) => sum + row.bytes, 0);
|
|
138
|
+
if (
|
|
139
|
+
counters.pending + fresh.length > maximumMessages ||
|
|
140
|
+
counters.pendingBytes + bytes > maximumBytes
|
|
141
|
+
) {
|
|
142
|
+
throw new Error('reception_inbox_full');
|
|
143
|
+
}
|
|
144
|
+
const insert = database.prepare(`
|
|
145
|
+
INSERT INTO reception_messages (
|
|
146
|
+
message_id, protocol, state, source_city, source_created_at,
|
|
147
|
+
source_name, message_kind, in_reply_to,
|
|
148
|
+
received_city_id, received_city_address, body, body_sha256,
|
|
149
|
+
connection_id, road_id, remote_message_id, received_at
|
|
150
|
+
) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
151
|
+
`);
|
|
152
|
+
const receivedAt = new Date().toISOString();
|
|
153
|
+
for (const row of fresh) {
|
|
154
|
+
const envelope = row.envelope;
|
|
155
|
+
insert.run(
|
|
156
|
+
envelope.id,
|
|
157
|
+
RECEPTION_PROTOCOL,
|
|
158
|
+
envelope.from.city,
|
|
159
|
+
envelope.createdAt,
|
|
160
|
+
row.sourceName,
|
|
161
|
+
row.kind,
|
|
162
|
+
row.inReplyTo,
|
|
163
|
+
context.city.id,
|
|
164
|
+
context.city.address,
|
|
165
|
+
row.body,
|
|
166
|
+
sha256(row.body),
|
|
167
|
+
optionalText(envelope.payload?.connectionId, 160),
|
|
168
|
+
optionalText(envelope.payload?.roadId, 160),
|
|
169
|
+
optionalText(envelope.payload?.remoteMessageId, 160),
|
|
170
|
+
receivedAt,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
applyAutomaticRouting(
|
|
174
|
+
database,
|
|
175
|
+
fresh.map((row) => row.envelope.id),
|
|
176
|
+
);
|
|
177
|
+
const updated = receptionCounters(database);
|
|
178
|
+
database.exec('COMMIT');
|
|
179
|
+
committed = true;
|
|
180
|
+
return { inserted: true, ...updated };
|
|
181
|
+
} finally {
|
|
182
|
+
if (!committed) {
|
|
183
|
+
try {
|
|
184
|
+
database.exec('ROLLBACK');
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateReceptionEnvelope(envelope: BusEnvelope) {
|
|
191
|
+
const body = envelope.payload?.text;
|
|
192
|
+
if (typeof body !== 'string' || !body || body.length > MAX_BODY) {
|
|
193
|
+
throw new Error('invalid_reception_message_body');
|
|
194
|
+
}
|
|
195
|
+
if (envelope.payload?.transport !== 'managed-e2ee') {
|
|
196
|
+
throw new Error('reception_accepts_managed_messages_only');
|
|
197
|
+
}
|
|
198
|
+
const kind = envelope.payload?.messageKind === 'rejection' ? 'rejection' : 'message';
|
|
199
|
+
const sourceName = optionalText(envelope.payload?.sourceName, 100) ?? envelope.from.city;
|
|
200
|
+
const inReplyTo = optionalText(envelope.payload?.inReplyTo, 180);
|
|
201
|
+
return { envelope, body, kind, sourceName, inReplyTo, bytes: Buffer.byteLength(body, 'utf8') };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type AutomaticRule = {
|
|
205
|
+
ruleId: string;
|
|
206
|
+
targetCityId: string;
|
|
207
|
+
targetCityAddress: string;
|
|
208
|
+
keywords: string[];
|
|
209
|
+
priority: number;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A capability-free classifier: deterministic owner rules in, one allowlisted
|
|
214
|
+
* destination out. It never interprets the text as instructions and cannot
|
|
215
|
+
* answer, fetch, execute, discover cities or access a model/tool credential.
|
|
216
|
+
*/
|
|
217
|
+
function applyAutomaticRouting(database: ReceptionDatabase, messageIds: string[]): void {
|
|
218
|
+
if (!messageIds.length) return;
|
|
219
|
+
const settings = database
|
|
220
|
+
.prepare(
|
|
221
|
+
`
|
|
222
|
+
SELECT routing_mode, router_profile FROM reception_settings WHERE singleton = 1
|
|
223
|
+
`,
|
|
224
|
+
)
|
|
225
|
+
.get() as { routing_mode?: string; router_profile?: string | null } | undefined;
|
|
226
|
+
if (settings?.routing_mode !== 'auto' || settings.router_profile !== AUTO_ROUTER_PROFILE) return;
|
|
227
|
+
const rawRules = database
|
|
228
|
+
.prepare(
|
|
229
|
+
`
|
|
230
|
+
SELECT rule_id, target_city_id, target_city_address, keywords_json, priority
|
|
231
|
+
FROM reception_auto_rules WHERE enabled = 1
|
|
232
|
+
ORDER BY priority DESC, rule_id
|
|
233
|
+
`,
|
|
234
|
+
)
|
|
235
|
+
.all() as unknown as Array<Record<string, unknown>>;
|
|
236
|
+
const rules = rawRules
|
|
237
|
+
.map(parseAutomaticRule)
|
|
238
|
+
.filter((rule): rule is AutomaticRule => Boolean(rule));
|
|
239
|
+
if (!rules.length) return;
|
|
240
|
+
const route = database.prepare(`
|
|
241
|
+
INSERT INTO reception_routes (
|
|
242
|
+
message_id, target_city_id, target_city_address, state, approved_by, approved_at
|
|
243
|
+
) VALUES (?, ?, ?, 'queued', 'auto', ?)
|
|
244
|
+
`);
|
|
245
|
+
const decide = database.prepare(`
|
|
246
|
+
UPDATE reception_messages
|
|
247
|
+
SET state = 'routed', decided_at = ?, decision_reason = ?
|
|
248
|
+
WHERE message_id = ? AND state = 'pending' AND message_kind = 'message'
|
|
249
|
+
`);
|
|
250
|
+
for (const messageId of messageIds) {
|
|
251
|
+
const message = database
|
|
252
|
+
.prepare(
|
|
253
|
+
`
|
|
254
|
+
SELECT body, message_kind FROM reception_messages
|
|
255
|
+
WHERE message_id = ? AND state = 'pending' LIMIT 1
|
|
256
|
+
`,
|
|
257
|
+
)
|
|
258
|
+
.get(messageId) as { body?: string | null; message_kind?: string } | undefined;
|
|
259
|
+
if (message?.message_kind !== 'message' || typeof message.body !== 'string') continue;
|
|
260
|
+
const selected = automaticDestination(message.body, rules);
|
|
261
|
+
if (!selected) continue;
|
|
262
|
+
const now = new Date().toISOString();
|
|
263
|
+
route.run(messageId, selected.targetCityId, selected.targetCityAddress, now);
|
|
264
|
+
const result = decide.run(now, `auto:${selected.ruleId}`, messageId);
|
|
265
|
+
if (Number(result.changes) !== 1) throw new Error('automatic_reception_decision_conflict');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseAutomaticRule(value: Record<string, unknown>): AutomaticRule | null {
|
|
270
|
+
let keywords: unknown;
|
|
271
|
+
try {
|
|
272
|
+
keywords = JSON.parse(String(value.keywords_json ?? ''));
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
if (
|
|
277
|
+
typeof value.rule_id !== 'string' ||
|
|
278
|
+
typeof value.target_city_id !== 'string' ||
|
|
279
|
+
typeof value.target_city_address !== 'string' ||
|
|
280
|
+
!Array.isArray(keywords) ||
|
|
281
|
+
keywords.length < 1 ||
|
|
282
|
+
keywords.length > 20 ||
|
|
283
|
+
!keywords.every((keyword) => typeof keyword === 'string' && keyword.length <= 80)
|
|
284
|
+
)
|
|
285
|
+
return null;
|
|
286
|
+
return {
|
|
287
|
+
ruleId: value.rule_id,
|
|
288
|
+
targetCityId: value.target_city_id,
|
|
289
|
+
targetCityAddress: value.target_city_address,
|
|
290
|
+
keywords: keywords.map((keyword) => normalizeForRouting(keyword as string)).filter(Boolean),
|
|
291
|
+
priority: Number(value.priority) || 0,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function automaticDestination(body: string, rules: AutomaticRule[]): AutomaticRule | null {
|
|
296
|
+
const normalized = normalizeForRouting(body);
|
|
297
|
+
if (!normalized || riskyAutomaticText(normalized)) return null;
|
|
298
|
+
const matches = rules
|
|
299
|
+
.map((rule) => ({
|
|
300
|
+
rule,
|
|
301
|
+
score: rule.keywords.reduce(
|
|
302
|
+
(score, keyword) => score + (keyword === '*' || normalized.includes(keyword) ? 1 : 0),
|
|
303
|
+
0,
|
|
304
|
+
),
|
|
305
|
+
}))
|
|
306
|
+
.filter((candidate) => candidate.score > 0);
|
|
307
|
+
if (!matches.length) return null;
|
|
308
|
+
matches.sort(
|
|
309
|
+
(left, right) =>
|
|
310
|
+
right.score - left.score ||
|
|
311
|
+
right.rule.priority - left.rule.priority ||
|
|
312
|
+
left.rule.ruleId.localeCompare(right.rule.ruleId),
|
|
313
|
+
);
|
|
314
|
+
const winner = matches[0]!;
|
|
315
|
+
const runnerUp = matches[1];
|
|
316
|
+
if (
|
|
317
|
+
runnerUp &&
|
|
318
|
+
runnerUp.score === winner.score &&
|
|
319
|
+
runnerUp.rule.priority === winner.rule.priority
|
|
320
|
+
)
|
|
321
|
+
return null;
|
|
322
|
+
return winner.rule;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function normalizeForRouting(value: string): string {
|
|
326
|
+
return value.normalize('NFKC').toLocaleLowerCase('en').replace(/\s+/g, ' ').trim();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function riskyAutomaticText(value: string): boolean {
|
|
330
|
+
return [
|
|
331
|
+
/<\|(?:im_start|im_end|system|developer)\|>/i,
|
|
332
|
+
/\[(?:system|inst|\/inst)\]/i,
|
|
333
|
+
/\b(?:ignore|disregard|override)\b.{0,40}\b(?:previous|system|developer|instructions?)\b/i,
|
|
334
|
+
/\b(?:system|developer)\s+(?:prompt|message)\b/i,
|
|
335
|
+
/\b(?:reveal|print|send|exfiltrate)\b.{0,50}\b(?:password|secret|api[ -]?key|token|credential)\b/i,
|
|
336
|
+
/\b(?:run|execute)\b.{0,30}\b(?:shell|command|terminal|tool)\b/i,
|
|
337
|
+
].some((pattern) => pattern.test(value));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Replace the trusted local connection directory with the latest signed API view. */
|
|
341
|
+
export function syncReceptionConnections(
|
|
342
|
+
appHome: string,
|
|
343
|
+
connections: ReceptionConnectionInput[],
|
|
344
|
+
): void {
|
|
345
|
+
const database = receptionDatabase(appHome);
|
|
346
|
+
let committed = false;
|
|
347
|
+
try {
|
|
348
|
+
database.exec('BEGIN IMMEDIATE');
|
|
349
|
+
database.prepare("UPDATE reception_connections SET status = 'inactive'").run();
|
|
350
|
+
const upsert = database.prepare(`
|
|
351
|
+
INSERT INTO reception_connections (
|
|
352
|
+
road_id, connection_id, peer_name, peer_endpoint, status, updated_at
|
|
353
|
+
) VALUES (?, ?, ?, ?, 'active', ?)
|
|
354
|
+
ON CONFLICT(road_id) DO UPDATE SET
|
|
355
|
+
connection_id = excluded.connection_id,
|
|
356
|
+
peer_name = excluded.peer_name,
|
|
357
|
+
peer_endpoint = excluded.peer_endpoint,
|
|
358
|
+
status = 'active',
|
|
359
|
+
updated_at = excluded.updated_at
|
|
360
|
+
`);
|
|
361
|
+
const now = new Date().toISOString();
|
|
362
|
+
for (const connection of connections) {
|
|
363
|
+
if (
|
|
364
|
+
!connection.roadId ||
|
|
365
|
+
!connection.connectionId ||
|
|
366
|
+
!connection.peerName.trim() ||
|
|
367
|
+
connection.peerName.length > 100 ||
|
|
368
|
+
!connection.peerEndpoint
|
|
369
|
+
)
|
|
370
|
+
throw new Error('invalid_reception_connection');
|
|
371
|
+
upsert.run(
|
|
372
|
+
connection.roadId,
|
|
373
|
+
connection.connectionId,
|
|
374
|
+
connection.peerName.trim(),
|
|
375
|
+
connection.peerEndpoint,
|
|
376
|
+
now,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
database.exec('COMMIT');
|
|
380
|
+
committed = true;
|
|
381
|
+
} finally {
|
|
382
|
+
if (!committed) {
|
|
383
|
+
try {
|
|
384
|
+
database.exec('ROLLBACK');
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function pendingReceptionOutbox(appHome: string, limit = 20): ReceptionOutboxRecord[] {
|
|
391
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) {
|
|
392
|
+
throw new Error('invalid_reception_outbox_batch');
|
|
393
|
+
}
|
|
394
|
+
return receptionDatabase(appHome)
|
|
395
|
+
.prepare(
|
|
396
|
+
`
|
|
397
|
+
SELECT o.message_id, o.road_id, o.connection_id, o.kind, o.body,
|
|
398
|
+
o.in_reply_to, o.attempt_count
|
|
399
|
+
FROM reception_outbox o
|
|
400
|
+
JOIN reception_connections c ON c.road_id = o.road_id
|
|
401
|
+
AND c.connection_id = o.connection_id
|
|
402
|
+
WHERE o.state = 'queued' AND o.body IS NOT NULL AND c.status = 'active'
|
|
403
|
+
AND (o.next_attempt_at IS NULL OR o.next_attempt_at <= ?)
|
|
404
|
+
ORDER BY o.created_at, o.message_id
|
|
405
|
+
LIMIT ?
|
|
406
|
+
`,
|
|
407
|
+
)
|
|
408
|
+
.all(Date.now(), limit)
|
|
409
|
+
.map((row) => {
|
|
410
|
+
const value = row as Record<string, unknown>;
|
|
411
|
+
return {
|
|
412
|
+
messageId: String(value.message_id),
|
|
413
|
+
roadId: String(value.road_id),
|
|
414
|
+
connectionId: String(value.connection_id),
|
|
415
|
+
kind: value.kind === 'rejection' ? 'rejection' : 'message',
|
|
416
|
+
body: String(value.body),
|
|
417
|
+
inReplyTo: typeof value.in_reply_to === 'string' ? value.in_reply_to : null,
|
|
418
|
+
attemptCount: Number(value.attempt_count),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function markReceptionOutboxSent(appHome: string, messageId: string): void {
|
|
424
|
+
receptionDatabase(appHome)
|
|
425
|
+
.prepare(
|
|
426
|
+
`
|
|
427
|
+
UPDATE reception_outbox
|
|
428
|
+
SET state = 'sent', body = NULL, sent_at = ?, next_attempt_at = NULL, error = NULL
|
|
429
|
+
WHERE message_id = ? AND state = 'queued'
|
|
430
|
+
`,
|
|
431
|
+
)
|
|
432
|
+
.run(new Date().toISOString(), messageId);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function markReceptionOutboxFailed(
|
|
436
|
+
appHome: string,
|
|
437
|
+
messageId: string,
|
|
438
|
+
attemptCount: number,
|
|
439
|
+
error: unknown,
|
|
440
|
+
): void {
|
|
441
|
+
const attempts = Math.max(1, attemptCount + 1);
|
|
442
|
+
const retryAt = Date.now() + Math.min(300_000, 1_000 * 2 ** Math.min(attempts - 1, 8));
|
|
443
|
+
receptionDatabase(appHome)
|
|
444
|
+
.prepare(
|
|
445
|
+
`
|
|
446
|
+
UPDATE reception_outbox
|
|
447
|
+
SET attempt_count = ?, last_attempt_at = ?, next_attempt_at = ?, error = ?
|
|
448
|
+
WHERE message_id = ? AND state = 'queued'
|
|
449
|
+
`,
|
|
450
|
+
)
|
|
451
|
+
.run(
|
|
452
|
+
attempts,
|
|
453
|
+
Date.now(),
|
|
454
|
+
retryAt,
|
|
455
|
+
String(error instanceof Error ? error.message : error).slice(0, 300),
|
|
456
|
+
messageId,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Move human-approved messages into this city's ordinary, bounded Road inbox.
|
|
462
|
+
* The deterministic message id makes a crash between local persistence and the
|
|
463
|
+
* SQLite route ACK an idempotent replay rather than a duplicate model turn.
|
|
464
|
+
*/
|
|
465
|
+
export function deliverApprovedReception(
|
|
466
|
+
context: CityContext,
|
|
467
|
+
limit = DELIVERY_BATCH,
|
|
468
|
+
): ReceptionDeliveryResult {
|
|
469
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > DELIVERY_BATCH) {
|
|
470
|
+
throw new Error('invalid_reception_delivery_batch');
|
|
471
|
+
}
|
|
472
|
+
const database = receptionDatabase(context.appHome);
|
|
473
|
+
const routes = database
|
|
474
|
+
.prepare(
|
|
475
|
+
`
|
|
476
|
+
SELECT m.message_id, m.source_city, m.source_created_at, m.body,
|
|
477
|
+
m.connection_id, m.road_id, r.approved_at, r.approved_by,
|
|
478
|
+
r.attempt_count
|
|
479
|
+
FROM reception_routes r
|
|
480
|
+
JOIN reception_messages m ON m.message_id = r.message_id
|
|
481
|
+
WHERE r.target_city_id = ? AND r.target_city_address = ?
|
|
482
|
+
AND r.state = 'queued' AND m.state = 'routed' AND m.body IS NOT NULL
|
|
483
|
+
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?)
|
|
484
|
+
ORDER BY r.approved_at, m.received_at, m.message_id
|
|
485
|
+
LIMIT ?
|
|
486
|
+
`,
|
|
487
|
+
)
|
|
488
|
+
.all(context.city.id, context.city.address, Date.now(), limit) as unknown as PendingRoute[];
|
|
489
|
+
let delivered = 0;
|
|
490
|
+
let failed = 0;
|
|
491
|
+
for (const route of routes) {
|
|
492
|
+
try {
|
|
493
|
+
const envelope: BusEnvelope = {
|
|
494
|
+
protocol: BUS_PROTOCOL,
|
|
495
|
+
id: route.message_id,
|
|
496
|
+
kind: 'road.message',
|
|
497
|
+
scope: 'road',
|
|
498
|
+
thread: null,
|
|
499
|
+
from: { city: route.source_city, actor: 'seat', role: 'external-seat' },
|
|
500
|
+
to: { city: context.city.address, actor: 'seat' },
|
|
501
|
+
createdAt: route.source_created_at,
|
|
502
|
+
payload: {
|
|
503
|
+
text: wrapUntrusted(route.body, route.source_city).text,
|
|
504
|
+
trust: 'information-not-authority',
|
|
505
|
+
transport: 'reception-approved',
|
|
506
|
+
reception: {
|
|
507
|
+
approvedAt: route.approved_at,
|
|
508
|
+
approvedBy: route.approved_by,
|
|
509
|
+
sourceMessageId: route.message_id,
|
|
510
|
+
...(route.connection_id ? { connectionId: route.connection_id } : {}),
|
|
511
|
+
...(route.road_id ? { roadId: route.road_id } : {}),
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
const accepted = recordRoadInbox(context.runtimeDir, envelope);
|
|
516
|
+
if (accepted) markRoadInboxAccepted(context.runtimeDir, envelope.id);
|
|
517
|
+
markRouteDelivered(database, route.message_id, context.city.id);
|
|
518
|
+
delivered += 1;
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (error instanceof Error && error.message === 'road_inbox_full') break;
|
|
521
|
+
markRouteFailed(database, route.message_id, context.city.id, error);
|
|
522
|
+
failed += 1;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const remaining = Number(
|
|
526
|
+
(
|
|
527
|
+
database
|
|
528
|
+
.prepare(
|
|
529
|
+
`
|
|
530
|
+
SELECT COUNT(*) AS count FROM reception_routes
|
|
531
|
+
WHERE target_city_id = ? AND target_city_address = ? AND state = 'queued'
|
|
532
|
+
`,
|
|
533
|
+
)
|
|
534
|
+
.get(context.city.id, context.city.address) as { count?: number } | undefined
|
|
535
|
+
)?.count ?? 0,
|
|
536
|
+
);
|
|
537
|
+
return { delivered, failed, remaining };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function receptionDatabase(appHome: string): ReceptionDatabase {
|
|
541
|
+
const path = receptionDatabasePath(appHome);
|
|
542
|
+
const cached = databases.get(path);
|
|
543
|
+
if (cached) return cached;
|
|
544
|
+
const directory = preparePrivateReceptionDirectory(appHome);
|
|
545
|
+
if (existsSync(path)) assertRegularPrivateDatabase(path);
|
|
546
|
+
const database = new DatabaseSync(path);
|
|
547
|
+
chmodSync(path, 0o600);
|
|
548
|
+
database.exec('PRAGMA journal_mode = WAL');
|
|
549
|
+
database.exec('PRAGMA synchronous = FULL');
|
|
550
|
+
database.exec('PRAGMA foreign_keys = ON');
|
|
551
|
+
database.exec('PRAGMA busy_timeout = 5000');
|
|
552
|
+
initializeSchema(database);
|
|
553
|
+
chmodSync(directory, 0o700);
|
|
554
|
+
databases.set(path, database);
|
|
555
|
+
return database;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function preparePrivateReceptionDirectory(appHome: string): string {
|
|
559
|
+
const home = realpathSync(resolve(appHome));
|
|
560
|
+
const runtime = join(home, '.runtime');
|
|
561
|
+
const reception = join(runtime, 'reception');
|
|
562
|
+
for (const path of [runtime, reception]) {
|
|
563
|
+
if (!existsSync(path)) mkdirSync(path, { mode: 0o700 });
|
|
564
|
+
const info = lstatSync(path);
|
|
565
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
566
|
+
throw new Error(`unsafe_reception_directory:${path}`);
|
|
567
|
+
}
|
|
568
|
+
chmodSync(path, 0o700);
|
|
569
|
+
}
|
|
570
|
+
return reception;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function assertRegularPrivateDatabase(path: string): void {
|
|
574
|
+
const info = lstatSync(path);
|
|
575
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('unsafe_reception_database');
|
|
576
|
+
chmodSync(path, 0o600);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function initializeSchema(database: ReceptionDatabase): void {
|
|
580
|
+
database.exec(`
|
|
581
|
+
CREATE TABLE IF NOT EXISTS reception_meta (
|
|
582
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
583
|
+
schema_version INTEGER NOT NULL,
|
|
584
|
+
created_at TEXT NOT NULL
|
|
585
|
+
);
|
|
586
|
+
CREATE TABLE IF NOT EXISTS reception_settings (
|
|
587
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
588
|
+
routing_mode TEXT NOT NULL DEFAULT 'manual' CHECK (routing_mode IN ('manual', 'auto')),
|
|
589
|
+
review_policy TEXT NOT NULL DEFAULT 'every_message' CHECK (review_policy IN ('every_message', 'new_thread')),
|
|
590
|
+
router_profile TEXT CHECK (router_profile IS NULL OR length(router_profile) <= 160),
|
|
591
|
+
updated_at TEXT NOT NULL,
|
|
592
|
+
CHECK (routing_mode = 'manual' OR router_profile IS NOT NULL)
|
|
593
|
+
);
|
|
594
|
+
CREATE TABLE IF NOT EXISTS reception_messages (
|
|
595
|
+
message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 1 AND 180),
|
|
596
|
+
protocol TEXT NOT NULL CHECK (protocol = '${RECEPTION_PROTOCOL}'),
|
|
597
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'routed', 'rejected', 'expired')),
|
|
598
|
+
source_city TEXT NOT NULL CHECK (length(source_city) BETWEEN 3 AND 160),
|
|
599
|
+
source_created_at TEXT NOT NULL,
|
|
600
|
+
source_name TEXT CHECK (source_name IS NULL OR length(source_name) <= 100),
|
|
601
|
+
message_kind TEXT NOT NULL DEFAULT 'message' CHECK (message_kind IN ('message', 'rejection')),
|
|
602
|
+
in_reply_to TEXT CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180),
|
|
603
|
+
received_city_id TEXT NOT NULL CHECK (length(received_city_id) BETWEEN 1 AND 160),
|
|
604
|
+
received_city_address TEXT NOT NULL CHECK (length(received_city_address) BETWEEN 3 AND 160),
|
|
605
|
+
body TEXT,
|
|
606
|
+
body_sha256 TEXT NOT NULL CHECK (length(body_sha256) = 64),
|
|
607
|
+
connection_id TEXT,
|
|
608
|
+
road_id TEXT,
|
|
609
|
+
remote_message_id TEXT,
|
|
610
|
+
received_at TEXT NOT NULL,
|
|
611
|
+
decided_at TEXT,
|
|
612
|
+
decision_reason TEXT CHECK (decision_reason IS NULL OR length(decision_reason) <= 500),
|
|
613
|
+
CHECK (state = 'pending' OR decided_at IS NOT NULL),
|
|
614
|
+
CHECK (state <> 'pending' OR body IS NOT NULL)
|
|
615
|
+
);
|
|
616
|
+
CREATE INDEX IF NOT EXISTS idx_reception_messages_state_age
|
|
617
|
+
ON reception_messages (state, received_at, message_id);
|
|
618
|
+
CREATE TABLE IF NOT EXISTS reception_routes (
|
|
619
|
+
message_id TEXT NOT NULL REFERENCES reception_messages(message_id) ON DELETE CASCADE,
|
|
620
|
+
target_city_id TEXT NOT NULL CHECK (length(target_city_id) BETWEEN 1 AND 160),
|
|
621
|
+
target_city_address TEXT NOT NULL CHECK (length(target_city_address) BETWEEN 3 AND 160),
|
|
622
|
+
state TEXT NOT NULL DEFAULT 'queued' CHECK (state IN ('queued', 'delivered', 'failed')),
|
|
623
|
+
approved_by TEXT NOT NULL CHECK (approved_by IN ('human', 'auto')),
|
|
624
|
+
approved_at TEXT NOT NULL,
|
|
625
|
+
delivered_at TEXT,
|
|
626
|
+
error TEXT CHECK (error IS NULL OR length(error) <= 300),
|
|
627
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
628
|
+
last_attempt_at INTEGER,
|
|
629
|
+
next_attempt_at INTEGER,
|
|
630
|
+
PRIMARY KEY (message_id, target_city_id)
|
|
631
|
+
);
|
|
632
|
+
CREATE INDEX IF NOT EXISTS idx_reception_routes_city_state
|
|
633
|
+
ON reception_routes (target_city_id, state, approved_at);
|
|
634
|
+
CREATE TABLE IF NOT EXISTS reception_counters (
|
|
635
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
636
|
+
pending_count INTEGER NOT NULL DEFAULT 0 CHECK (pending_count >= 0),
|
|
637
|
+
pending_bytes INTEGER NOT NULL DEFAULT 0 CHECK (pending_bytes >= 0)
|
|
638
|
+
);
|
|
639
|
+
CREATE TABLE IF NOT EXISTS reception_connections (
|
|
640
|
+
road_id TEXT PRIMARY KEY,
|
|
641
|
+
connection_id TEXT NOT NULL UNIQUE,
|
|
642
|
+
peer_name TEXT NOT NULL CHECK (length(peer_name) BETWEEN 1 AND 100),
|
|
643
|
+
peer_endpoint TEXT NOT NULL CHECK (length(peer_endpoint) BETWEEN 3 AND 160),
|
|
644
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'inactive')),
|
|
645
|
+
updated_at TEXT NOT NULL
|
|
646
|
+
);
|
|
647
|
+
CREATE TABLE IF NOT EXISTS reception_outbox (
|
|
648
|
+
message_id TEXT PRIMARY KEY CHECK (length(message_id) = 36),
|
|
649
|
+
road_id TEXT NOT NULL,
|
|
650
|
+
connection_id TEXT NOT NULL,
|
|
651
|
+
kind TEXT NOT NULL CHECK (kind IN ('message', 'rejection')),
|
|
652
|
+
body TEXT,
|
|
653
|
+
in_reply_to TEXT CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180),
|
|
654
|
+
state TEXT NOT NULL DEFAULT 'queued' CHECK (state IN ('queued', 'sent')),
|
|
655
|
+
created_at TEXT NOT NULL,
|
|
656
|
+
sent_at TEXT,
|
|
657
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
658
|
+
last_attempt_at INTEGER,
|
|
659
|
+
next_attempt_at INTEGER,
|
|
660
|
+
error TEXT CHECK (error IS NULL OR length(error) <= 300)
|
|
661
|
+
);
|
|
662
|
+
CREATE INDEX IF NOT EXISTS idx_reception_outbox_state_age
|
|
663
|
+
ON reception_outbox (state, next_attempt_at, created_at);
|
|
664
|
+
CREATE TABLE IF NOT EXISTS reception_auto_rules (
|
|
665
|
+
rule_id TEXT PRIMARY KEY CHECK (length(rule_id) = 36),
|
|
666
|
+
target_city_id TEXT NOT NULL UNIQUE CHECK (length(target_city_id) BETWEEN 1 AND 160),
|
|
667
|
+
target_city_address TEXT NOT NULL CHECK (length(target_city_address) BETWEEN 3 AND 160),
|
|
668
|
+
keywords_json TEXT NOT NULL CHECK (length(keywords_json) BETWEEN 5 AND 2000),
|
|
669
|
+
priority INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 1000),
|
|
670
|
+
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
|
671
|
+
updated_at TEXT NOT NULL
|
|
672
|
+
);
|
|
673
|
+
CREATE TRIGGER IF NOT EXISTS reception_message_count_after_insert
|
|
674
|
+
AFTER INSERT ON reception_messages WHEN NEW.state = 'pending'
|
|
675
|
+
BEGIN
|
|
676
|
+
UPDATE reception_counters
|
|
677
|
+
SET pending_count = pending_count + 1,
|
|
678
|
+
pending_bytes = pending_bytes + length(CAST(NEW.body AS BLOB))
|
|
679
|
+
WHERE singleton = 1;
|
|
680
|
+
END;
|
|
681
|
+
CREATE TRIGGER IF NOT EXISTS reception_message_count_after_decision
|
|
682
|
+
AFTER UPDATE OF state ON reception_messages
|
|
683
|
+
WHEN OLD.state = 'pending' AND NEW.state <> 'pending'
|
|
684
|
+
BEGIN
|
|
685
|
+
UPDATE reception_counters
|
|
686
|
+
SET pending_count = MAX(0, pending_count - 1),
|
|
687
|
+
pending_bytes = MAX(0, pending_bytes - length(CAST(OLD.body AS BLOB)))
|
|
688
|
+
WHERE singleton = 1;
|
|
689
|
+
END;
|
|
690
|
+
CREATE TRIGGER IF NOT EXISTS reception_message_count_after_delete
|
|
691
|
+
AFTER DELETE ON reception_messages WHEN OLD.state = 'pending'
|
|
692
|
+
BEGIN
|
|
693
|
+
UPDATE reception_counters
|
|
694
|
+
SET pending_count = MAX(0, pending_count - 1),
|
|
695
|
+
pending_bytes = MAX(0, pending_bytes - length(CAST(OLD.body AS BLOB)))
|
|
696
|
+
WHERE singleton = 1;
|
|
697
|
+
END;
|
|
698
|
+
`);
|
|
699
|
+
const meta = database
|
|
700
|
+
.prepare('SELECT schema_version FROM reception_meta WHERE singleton = 1')
|
|
701
|
+
.get() as { schema_version: number } | undefined;
|
|
702
|
+
if (meta?.schema_version === 1) {
|
|
703
|
+
database.exec('BEGIN IMMEDIATE');
|
|
704
|
+
try {
|
|
705
|
+
const current = database
|
|
706
|
+
.prepare('SELECT schema_version FROM reception_meta WHERE singleton = 1')
|
|
707
|
+
.get() as { schema_version: number } | undefined;
|
|
708
|
+
if (current?.schema_version === 1) {
|
|
709
|
+
database.exec(`
|
|
710
|
+
ALTER TABLE reception_messages ADD COLUMN source_name TEXT
|
|
711
|
+
CHECK (source_name IS NULL OR length(source_name) <= 100);
|
|
712
|
+
ALTER TABLE reception_messages ADD COLUMN message_kind TEXT NOT NULL DEFAULT 'message'
|
|
713
|
+
CHECK (message_kind IN ('message', 'rejection'));
|
|
714
|
+
ALTER TABLE reception_messages ADD COLUMN in_reply_to TEXT
|
|
715
|
+
CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180);
|
|
716
|
+
UPDATE reception_meta SET schema_version = ${RECEPTION_SCHEMA_VERSION} WHERE singleton = 1;
|
|
717
|
+
`);
|
|
718
|
+
} else if (current?.schema_version === 2) {
|
|
719
|
+
database
|
|
720
|
+
.prepare('UPDATE reception_meta SET schema_version = ? WHERE singleton = 1')
|
|
721
|
+
.run(RECEPTION_SCHEMA_VERSION);
|
|
722
|
+
} else if (current?.schema_version !== RECEPTION_SCHEMA_VERSION) {
|
|
723
|
+
throw new Error(`unsupported_reception_schema:${current?.schema_version ?? 'missing'}`);
|
|
724
|
+
}
|
|
725
|
+
database.exec('COMMIT');
|
|
726
|
+
} catch (error) {
|
|
727
|
+
try {
|
|
728
|
+
database.exec('ROLLBACK');
|
|
729
|
+
} catch {}
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
} else if (meta && meta.schema_version !== RECEPTION_SCHEMA_VERSION) {
|
|
733
|
+
throw new Error(`unsupported_reception_schema:${meta.schema_version}`);
|
|
734
|
+
}
|
|
735
|
+
database
|
|
736
|
+
.prepare(
|
|
737
|
+
`
|
|
738
|
+
INSERT OR IGNORE INTO reception_meta (singleton, schema_version, created_at)
|
|
739
|
+
VALUES (1, ?, ?)
|
|
740
|
+
`,
|
|
741
|
+
)
|
|
742
|
+
.run(RECEPTION_SCHEMA_VERSION, new Date().toISOString());
|
|
743
|
+
database
|
|
744
|
+
.prepare(
|
|
745
|
+
`
|
|
746
|
+
INSERT OR IGNORE INTO reception_settings (
|
|
747
|
+
singleton, routing_mode, review_policy, router_profile, updated_at
|
|
748
|
+
) VALUES (1, 'manual', 'every_message', NULL, ?)
|
|
749
|
+
`,
|
|
750
|
+
)
|
|
751
|
+
.run(new Date().toISOString());
|
|
752
|
+
database
|
|
753
|
+
.prepare(
|
|
754
|
+
`
|
|
755
|
+
INSERT OR IGNORE INTO reception_counters (singleton, pending_count, pending_bytes)
|
|
756
|
+
VALUES (1, 0, 0)
|
|
757
|
+
`,
|
|
758
|
+
)
|
|
759
|
+
.run();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function receptionCounters(database: ReceptionDatabase) {
|
|
763
|
+
const row = database
|
|
764
|
+
.prepare('SELECT pending_count, pending_bytes FROM reception_counters WHERE singleton = 1')
|
|
765
|
+
.get() as { pending_count?: number; pending_bytes?: number } | undefined;
|
|
766
|
+
return {
|
|
767
|
+
pending: Number(row?.pending_count ?? 0),
|
|
768
|
+
pendingBytes: Number(row?.pending_bytes ?? 0),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function expireOldReception(database: ReceptionDatabase): void {
|
|
773
|
+
const retentionDays = boundedInteger(process.env.CITY_RECEPTION_PENDING_DAYS, 30, 1, 90);
|
|
774
|
+
database
|
|
775
|
+
.prepare(
|
|
776
|
+
`
|
|
777
|
+
UPDATE reception_messages
|
|
778
|
+
SET state = 'expired', body = NULL, decided_at = ?, decision_reason = 'expired locally'
|
|
779
|
+
WHERE state = 'pending' AND received_at < ?
|
|
780
|
+
`,
|
|
781
|
+
)
|
|
782
|
+
.run(
|
|
783
|
+
new Date().toISOString(),
|
|
784
|
+
new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1_000).toISOString(),
|
|
785
|
+
);
|
|
786
|
+
database
|
|
787
|
+
.prepare(
|
|
788
|
+
`
|
|
789
|
+
DELETE FROM reception_messages
|
|
790
|
+
WHERE state IN ('rejected', 'expired') AND decided_at < ?
|
|
791
|
+
`,
|
|
792
|
+
)
|
|
793
|
+
.run(new Date(Date.now() - 30 * 24 * 60 * 60 * 1_000).toISOString());
|
|
794
|
+
database
|
|
795
|
+
.prepare(
|
|
796
|
+
`
|
|
797
|
+
DELETE FROM reception_messages
|
|
798
|
+
WHERE state = 'routed' AND body IS NULL AND decided_at < ?
|
|
799
|
+
`,
|
|
800
|
+
)
|
|
801
|
+
.run(new Date(Date.now() - 30 * 24 * 60 * 60 * 1_000).toISOString());
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function markRouteDelivered(database: ReceptionDatabase, messageId: string, cityId: string): void {
|
|
805
|
+
let committed = false;
|
|
806
|
+
try {
|
|
807
|
+
database.exec('BEGIN IMMEDIATE');
|
|
808
|
+
database
|
|
809
|
+
.prepare(
|
|
810
|
+
`
|
|
811
|
+
UPDATE reception_routes
|
|
812
|
+
SET state = 'delivered', delivered_at = ?, error = NULL,
|
|
813
|
+
next_attempt_at = NULL
|
|
814
|
+
WHERE message_id = ? AND target_city_id = ? AND state = 'queued'
|
|
815
|
+
`,
|
|
816
|
+
)
|
|
817
|
+
.run(new Date().toISOString(), messageId, cityId);
|
|
818
|
+
const waiting = Number(
|
|
819
|
+
(
|
|
820
|
+
database
|
|
821
|
+
.prepare(
|
|
822
|
+
`
|
|
823
|
+
SELECT COUNT(*) AS count FROM reception_routes
|
|
824
|
+
WHERE message_id = ? AND state <> 'delivered'
|
|
825
|
+
`,
|
|
826
|
+
)
|
|
827
|
+
.get(messageId) as { count?: number } | undefined
|
|
828
|
+
)?.count ?? 0,
|
|
829
|
+
);
|
|
830
|
+
if (!waiting) {
|
|
831
|
+
database
|
|
832
|
+
.prepare(
|
|
833
|
+
`
|
|
834
|
+
UPDATE reception_messages SET body = NULL
|
|
835
|
+
WHERE message_id = ? AND state = 'routed'
|
|
836
|
+
`,
|
|
837
|
+
)
|
|
838
|
+
.run(messageId);
|
|
839
|
+
}
|
|
840
|
+
database.exec('COMMIT');
|
|
841
|
+
committed = true;
|
|
842
|
+
} finally {
|
|
843
|
+
if (!committed) {
|
|
844
|
+
try {
|
|
845
|
+
database.exec('ROLLBACK');
|
|
846
|
+
} catch {}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function markRouteFailed(
|
|
852
|
+
database: ReceptionDatabase,
|
|
853
|
+
messageId: string,
|
|
854
|
+
cityId: string,
|
|
855
|
+
error: unknown,
|
|
856
|
+
): void {
|
|
857
|
+
const reason = String(error instanceof Error ? error.message : error).slice(0, 300);
|
|
858
|
+
const row = database
|
|
859
|
+
.prepare(
|
|
860
|
+
`
|
|
861
|
+
SELECT attempt_count FROM reception_routes
|
|
862
|
+
WHERE message_id = ? AND target_city_id = ? AND state = 'queued'
|
|
863
|
+
`,
|
|
864
|
+
)
|
|
865
|
+
.get(messageId, cityId) as { attempt_count?: number } | undefined;
|
|
866
|
+
const attempts = Number(row?.attempt_count ?? 0) + 1;
|
|
867
|
+
const now = Date.now();
|
|
868
|
+
const retryAt = now + Math.min(300_000, 1_000 * 2 ** Math.min(attempts - 1, 8));
|
|
869
|
+
database
|
|
870
|
+
.prepare(
|
|
871
|
+
`
|
|
872
|
+
UPDATE reception_routes
|
|
873
|
+
SET attempt_count = ?, last_attempt_at = ?, next_attempt_at = ?, error = ?
|
|
874
|
+
WHERE message_id = ? AND target_city_id = ? AND state = 'queued'
|
|
875
|
+
`,
|
|
876
|
+
)
|
|
877
|
+
.run(attempts, now, retryAt, reason, messageId, cityId);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function optionalText(value: unknown, maximum: number): string | null {
|
|
881
|
+
return typeof value === 'string' && value ? value.slice(0, maximum) : null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function sha256(value: string): string {
|
|
885
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function boundedInteger(
|
|
889
|
+
raw: string | undefined,
|
|
890
|
+
fallback: number,
|
|
891
|
+
minimum: number,
|
|
892
|
+
maximum: number,
|
|
893
|
+
): number {
|
|
894
|
+
const value = Number(raw);
|
|
895
|
+
return Number.isSafeInteger(value) && value >= minimum && value <= maximum ? value : fallback;
|
|
896
|
+
}
|