@twinsearth/w2m-dsh-plugin 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +9217 -9123
- package/README.md +3 -2
- package/codex/mcp-servers.toml +1 -1
- package/lib/client.js +91 -6
- package/package.json +100 -93
- package/plugin.json +1 -1
- package/src/plugin/council-fleet.mjs +221 -0
- package/src/plugin/dataflow.mjs +72 -0
- package/src/plugin/fleet-chat-route.mjs +86 -1
- package/src/plugin/intents.mjs +137 -0
- package/src/plugin/memory-view.mjs +60 -0
- package/src/plugin/schema-spec.mjs +49 -0
- package/src/plugin/tools.mjs +183 -3
- package/src/relay/fleet-memory.mjs +85 -0
- package/src/relay/server.mjs +34 -1
package/src/plugin/tools.mjs
CHANGED
|
@@ -183,6 +183,10 @@ import {
|
|
|
183
183
|
// v1.1.17 chat: the host route behind the composer's fleet-chat panel. The relay side and the
|
|
184
184
|
// `w2m_chat` tool shipped first; this is the browser-facing half that was still missing.
|
|
185
185
|
import { registerChatRoutes } from './fleet-chat-route.mjs';
|
|
186
|
+
import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
|
|
187
|
+
import { renderMemoryView } from './memory-view.mjs';
|
|
188
|
+
import { schemaProblems } from './schema-spec.mjs';
|
|
189
|
+
import { listIntents, resolveIntent } from './intents.mjs';
|
|
186
190
|
// v0.7.0: the LAN hint layer. Imported here and used in exactly one place (`w2m_devices`); see the
|
|
187
191
|
// module header for why a discovered device is a candidate to *ask* and never an authorisation.
|
|
188
192
|
import { createDiscoverySource } from './mdns-discover.mjs';
|
|
@@ -220,7 +224,7 @@ const MAX_STAGES = 16;
|
|
|
220
224
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
221
225
|
* overrides it at runtime for tests and packagers.
|
|
222
226
|
*/
|
|
223
|
-
const PLUGIN_VERSION = '1.2.
|
|
227
|
+
const PLUGIN_VERSION = '1.2.3';
|
|
224
228
|
|
|
225
229
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
226
230
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -553,6 +557,15 @@ function createDefineTool(real) {
|
|
|
553
557
|
parameters[key] = normaliseParameter(name, key, decl);
|
|
554
558
|
}
|
|
555
559
|
|
|
560
|
+
// v1.2.3: DSH refuses an object parameter that does not state additionalProperties explicitly, and it
|
|
561
|
+
// refuses it at MOUNT time -- in the profile of whoever installed the build. That is the failure the packed
|
|
562
|
+
// artifact hit in a real host while this repository tests were green, so the rule is enforced here, where
|
|
563
|
+
// every tool already passes through.
|
|
564
|
+
const schemaProblemsFound = schemaProblems({ type: 'object', properties: parameters, additionalProperties: false }, name);
|
|
565
|
+
if (schemaProblemsFound.length > 0) {
|
|
566
|
+
throw new TypeError(schemaProblemsFound.map((problem) => problem.path + ': ' + problem.why).join('; '));
|
|
567
|
+
}
|
|
568
|
+
|
|
556
569
|
return { ...definition, parameters };
|
|
557
570
|
};
|
|
558
571
|
|
|
@@ -3390,6 +3403,10 @@ export async function apply(ctx, config = {}) {
|
|
|
3390
3403
|
*/
|
|
3391
3404
|
const councilSessions = createCouncilSessions();
|
|
3392
3405
|
|
|
3406
|
+
/** v1.2.2: the machine-wide council. Ballots arrive as chat messages, so this is state + arithmetic; the transport is
|
|
3407
|
+
* the fleet chat itself (thread `council-<id>`), which already carries `to` / `thread_id` / `reply_to` since 1.2.1. */
|
|
3408
|
+
const fleetCouncil = createFleetCouncil({});
|
|
3409
|
+
|
|
3393
3410
|
// v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
|
|
3394
3411
|
// host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence - // which is the honest place for it, since a host that has no Connection at all is simply not a web
|
|
3395
3412
|
// composition and does not need a warning on every headless run.
|
|
@@ -5234,7 +5251,7 @@ export async function apply(ctx, config = {}) {
|
|
|
5234
5251
|
*/
|
|
5235
5252
|
action: {
|
|
5236
5253
|
type: 'string',
|
|
5237
|
-
enum: ['read', 'publish', 'pull', 'latest', 'diff', 'merge'],
|
|
5254
|
+
enum: ['read', 'publish', 'pull', 'latest', 'diff', 'merge', 'append'],
|
|
5238
5255
|
description:
|
|
5239
5256
|
'read (default) = answer from memory. publish = upload the curated collective memory '
|
|
5240
5257
|
+ '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256. latest = ask the '
|
|
@@ -5252,6 +5269,19 @@ export async function apply(ctx, config = {}) {
|
|
|
5252
5269
|
+ 'relay but belongs to another fleet is reported as not found, exactly like a hash that was never '
|
|
5253
5270
|
+ 'stored -- a distinguishable refusal would confirm to a prober which hashes this relay holds.',
|
|
5254
5271
|
},
|
|
5272
|
+
records: {
|
|
5273
|
+
type: 'array',
|
|
5274
|
+
items: { type: 'string' },
|
|
5275
|
+
description:
|
|
5276
|
+
'For action="append": one append-only record per string. Appending is the unit of change, so two machines ' +
|
|
5277
|
+
'can add records without one overwriting the other document.',
|
|
5278
|
+
},
|
|
5279
|
+
if_revision: {
|
|
5280
|
+
type: 'number',
|
|
5281
|
+
description:
|
|
5282
|
+
'For action="append": the revision you read. A stale value is refused with 409 and the records you have not ' +
|
|
5283
|
+
'read, because applying it on top of something you never saw is a silent overwrite.',
|
|
5284
|
+
},
|
|
5255
5285
|
scope: {
|
|
5256
5286
|
type: 'string',
|
|
5257
5287
|
enum: ['local', 'fleet'],
|
|
@@ -5340,6 +5370,64 @@ export async function apply(ctx, config = {}) {
|
|
|
5340
5370
|
async execute(args, exec) {
|
|
5341
5371
|
throwIfAborted(exec?.signal);
|
|
5342
5372
|
|
|
5373
|
+
/**
|
|
5374
|
+
* v1.2.2: append records to the shared memory under compare-and-swap.
|
|
5375
|
+
*
|
|
5376
|
+
* A 409 is returned AS-IS, with the revision and what the caller has not read. Retrying here would turn a visible
|
|
5377
|
+
* conflict into a silent overwrite, which is the whole reason the relay keeps a revision instead of merging.
|
|
5378
|
+
*/
|
|
5379
|
+
if (typeof args?.action === 'string' && args.action === 'append') {
|
|
5380
|
+
const doc = typeof args?.scope === 'string' && args.scope.trim() !== '' ? args.scope.trim() : 'fleet';
|
|
5381
|
+
const incoming = Array.isArray(args?.records)
|
|
5382
|
+
? args.records
|
|
5383
|
+
: (typeof args?.records === 'string' ? [args.records] : []);
|
|
5384
|
+
const records = incoming.filter((entry) => typeof entry === 'string' && entry.trim() !== '');
|
|
5385
|
+
if (records.length === 0) {
|
|
5386
|
+
throw new W2MError('W2M_MEMORY_APPEND', 'W2M_MEMORY_APPEND: records is required for append', {
|
|
5387
|
+
hint: 'pass records: ["a fact the fleet should keep", ...]',
|
|
5388
|
+
});
|
|
5389
|
+
}
|
|
5390
|
+
const appendUrl = cfg.rabbitUrl ?? null;
|
|
5391
|
+
if (appendUrl === null) {
|
|
5392
|
+
throw new W2MError('W2M_NO_RELAY', 'W2M_NO_RELAY: no relay is configured, so shared memory cannot be written', {
|
|
5393
|
+
hint: 'set rabbitUrl in the profile patch',
|
|
5394
|
+
});
|
|
5395
|
+
}
|
|
5396
|
+
const appendToken = await resolveToken(cfg);
|
|
5397
|
+
if (appendToken === null) {
|
|
5398
|
+
throw new W2MError('W2M_NO_TOKEN', 'W2M_NO_TOKEN: this machine has no device token, so shared memory cannot be written', {
|
|
5399
|
+
hint: 'pair this machine first, then retry',
|
|
5400
|
+
});
|
|
5401
|
+
}
|
|
5402
|
+
const appendBody = { records };
|
|
5403
|
+
if (Number.isInteger(args?.if_revision)) appendBody.if_revision = args.if_revision;
|
|
5404
|
+
const appended = await request({
|
|
5405
|
+
rabbitUrl: appendUrl,
|
|
5406
|
+
token: appendToken,
|
|
5407
|
+
method: 'PUT',
|
|
5408
|
+
pathname: '/v1/fleet/memory/' + encodeURIComponent(doc),
|
|
5409
|
+
body: appendBody,
|
|
5410
|
+
signal: exec?.signal,
|
|
5411
|
+
});
|
|
5412
|
+
if (appended?.ok !== true) {
|
|
5413
|
+
return JSON.stringify({
|
|
5414
|
+
ok: false,
|
|
5415
|
+
action: 'append',
|
|
5416
|
+
doc,
|
|
5417
|
+
status: appended?.status ?? 0,
|
|
5418
|
+
code: appended?.json?.error?.code ?? appended?.error?.code ?? 'W2M_MEMORY_APPEND',
|
|
5419
|
+
detail: appended?.json?.detail ?? null,
|
|
5420
|
+
});
|
|
5421
|
+
}
|
|
5422
|
+
return JSON.stringify({
|
|
5423
|
+
ok: true,
|
|
5424
|
+
action: 'append',
|
|
5425
|
+
doc,
|
|
5426
|
+
revision: appended.json?.revision ?? null,
|
|
5427
|
+
appended: appended.json?.appended ?? 0,
|
|
5428
|
+
});
|
|
5429
|
+
}
|
|
5430
|
+
|
|
5343
5431
|
/**
|
|
5344
5432
|
* v1.0.7 (#1c): publish the collective memory as a content-addressed blob.
|
|
5345
5433
|
*
|
|
@@ -6096,6 +6184,7 @@ export async function apply(ctx, config = {}) {
|
|
|
6096
6184
|
truncated: current.truncated,
|
|
6097
6185
|
status_tally: tally(current.records),
|
|
6098
6186
|
records: current.records,
|
|
6187
|
+
view: renderMemoryView(current.records),
|
|
6099
6188
|
...(previous === null
|
|
6100
6189
|
? {}
|
|
6101
6190
|
: {
|
|
@@ -6414,6 +6503,44 @@ export async function apply(ctx, config = {}) {
|
|
|
6414
6503
|
},
|
|
6415
6504
|
}));
|
|
6416
6505
|
|
|
6506
|
+
/**
|
|
6507
|
+
* v1.2.2: structured intents -- the deterministic half of "say it in words, run it on the machine".
|
|
6508
|
+
*
|
|
6509
|
+
* The caller is the model, and it turns language into one of a CLOSED set of intents; this tool maps that intent to an
|
|
6510
|
+
* allow-listed argv or refuses it by name. Free text is deliberately not interpreted here: a remote machine runs an
|
|
6511
|
+
* allow-list, and a model inventing a command on its behalf is exactly what the list exists to stop.
|
|
6512
|
+
*/
|
|
6513
|
+
ctx.tools.register(defineTool({
|
|
6514
|
+
name: 'w2m_intent',
|
|
6515
|
+
description:
|
|
6516
|
+
'Map a structured intent to an allow-listed command. Actions: run-tests, node-version, git-status, git-head, ' +
|
|
6517
|
+
'fleet-facts, shard-probe. Unknown actions and unknown parameters are refused by name rather than guessed, and a ' +
|
|
6518
|
+
'command the machine allow-list does not carry is refused with the list it checked.',
|
|
6519
|
+
parameters: {
|
|
6520
|
+
action: { type: 'string', description: 'The catalog action; call with action:"list" to read the catalog.' },
|
|
6521
|
+
params: {
|
|
6522
|
+
type: 'object',
|
|
6523
|
+
additionalProperties: true,
|
|
6524
|
+
description: 'Action parameters, for example { n: 3 } for shard-probe.',
|
|
6525
|
+
},
|
|
6526
|
+
},
|
|
6527
|
+
output: jsonOutput,
|
|
6528
|
+
presentCall: (args) => ({ card: 'generic', title: 'w2m intent ' + (typeof args?.action === 'string' ? args.action : '?'), kind: 'read', rawInput: {} }),
|
|
6529
|
+
async execute(args, exec) {
|
|
6530
|
+
throwIfAborted(exec?.signal);
|
|
6531
|
+
const action = typeof args?.action === 'string' ? args.action.trim() : '';
|
|
6532
|
+
if (action === '' || action === 'list') {
|
|
6533
|
+
return JSON.stringify({ ok: true, action: 'list', intents: listIntents() });
|
|
6534
|
+
}
|
|
6535
|
+
const resolved = resolveIntent({ action, params: args?.params }, { allowedCommands: cfg.allowedCommands ?? null });
|
|
6536
|
+
if (resolved.ok !== true) {
|
|
6537
|
+
return JSON.stringify({ ok: false, action, code: resolved.code, detail: resolved.detail ?? null, hint: resolved.hint });
|
|
6538
|
+
}
|
|
6539
|
+
return JSON.stringify({ ok: true, action, argv: resolved.argv, command: resolved.command, risk: resolved.risk });
|
|
6540
|
+
},
|
|
6541
|
+
}));
|
|
6542
|
+
|
|
6543
|
+
|
|
6417
6544
|
/**
|
|
6418
6545
|
* v0.37.0: the eleventh tool -- a question put to a council, and the outcome read back with its dissent.
|
|
6419
6546
|
*
|
|
@@ -6443,7 +6570,7 @@ export async function apply(ctx, config = {}) {
|
|
|
6443
6570
|
parameters: {
|
|
6444
6571
|
action: {
|
|
6445
6572
|
type: 'string',
|
|
6446
|
-
enum: ['open', 'advance', 'assign', 'argue', 'vote', 'decide', 'record', 'state', 'decision', 'list'],
|
|
6573
|
+
enum: ['open', 'advance', 'assign', 'argue', 'vote', 'decide', 'record', 'state', 'decision', 'list', 'fleet-open', 'fleet-vote', 'fleet-decide', 'fleet-state', 'fleet-list'],
|
|
6447
6574
|
description: 'What to do. "open" starts a council, "state" reports one mid-flight, "decision" reads a sealed one back with its dissent.',
|
|
6448
6575
|
default: 'state',
|
|
6449
6576
|
},
|
|
@@ -6474,6 +6601,59 @@ export async function apply(ctx, config = {}) {
|
|
|
6474
6601
|
const action = typeof args?.action === 'string' ? args.action : 'state';
|
|
6475
6602
|
const id = typeof args?.council_id === 'string' ? args.council_id : null;
|
|
6476
6603
|
|
|
6604
|
+
// v1.2.2: the FLEET council. Same decision rule as the single-machine one (reproducible weights, a tie is a
|
|
6605
|
+
// refusal); the difference is transport -- ballots are chat messages on `council-<id>`, so every machine can read
|
|
6606
|
+
// the same ballots and recount to the same outcome.
|
|
6607
|
+
if (typeof action === 'string' && action.startsWith('fleet-')) {
|
|
6608
|
+
const threadId = fleetCouncilThread(id);
|
|
6609
|
+
if (action === 'fleet-open') {
|
|
6610
|
+
const opened = fleetCouncil.open({ councilId: id, question: typeof args?.question === 'string' ? args.question : '', openedByMachineId: 'local' });
|
|
6611
|
+
return JSON.stringify({ ok: opened.ok, action, council_id: id, thread_id: threadId, reason_code: opened.code ?? null, council: opened.council ?? null });
|
|
6612
|
+
}
|
|
6613
|
+
if (action === 'fleet-vote') {
|
|
6614
|
+
const voted = fleetCouncil.vote({
|
|
6615
|
+
councilId: id,
|
|
6616
|
+
machineId: typeof args?.participant === 'string' && args.participant !== '' ? args.participant : 'local',
|
|
6617
|
+
choice: args?.choice,
|
|
6618
|
+
reason: args?.reason ?? null,
|
|
6619
|
+
authority: args?.authority ?? null,
|
|
6620
|
+
reputation: args?.reputation ?? null,
|
|
6621
|
+
});
|
|
6622
|
+
return JSON.stringify({ ok: voted.ok, action, council_id: id, thread_id: threadId, reason_code: voted.code ?? null, council: voted.council ?? null });
|
|
6623
|
+
}
|
|
6624
|
+
if (action === 'fleet-decide') {
|
|
6625
|
+
const decided = fleetCouncil.decide({ councilId: id });
|
|
6626
|
+
// The decision travels as an append-only memory record: a decision that exists only in one process memory
|
|
6627
|
+
// is a decision nobody can audit later. Writing it is best-effort -- the outcome is returned either way and
|
|
6628
|
+
// `trail` says which happened, rather than implying the record exists when it does not.
|
|
6629
|
+
let trail = 'not-written';
|
|
6630
|
+
if (decided.ok === true && decided.council?.decision != null) {
|
|
6631
|
+
try {
|
|
6632
|
+
const trailUrl = cfg?.rabbitUrl ?? null;
|
|
6633
|
+
const trailToken = trailUrl === null ? null : await resolveToken(cfg);
|
|
6634
|
+
if (trailUrl !== null && typeof trailToken === 'string' && trailToken !== '') {
|
|
6635
|
+
const decision = decided.council.decision;
|
|
6636
|
+
const line = 'council ' + String(id) + ': ' + String(decision.choice) + ' (weight ' + String(decision.weight) + ', '
|
|
6637
|
+
+ String(decided.council.ballots.length) + ' ballot(s))';
|
|
6638
|
+
const appended = await request({
|
|
6639
|
+
rabbitUrl: trailUrl, token: trailToken, method: 'PUT',
|
|
6640
|
+
pathname: '/v1/fleet/memory/fleet', body: { records: [line] }, signal: exec?.signal,
|
|
6641
|
+
});
|
|
6642
|
+
trail = appended?.ok === true ? 'appended' : 'refused';
|
|
6643
|
+
}
|
|
6644
|
+
} catch {
|
|
6645
|
+
trail = 'error';
|
|
6646
|
+
}
|
|
6647
|
+
}
|
|
6648
|
+
return JSON.stringify({ ok: decided.ok, action, council_id: id, thread_id: threadId, reason_code: decided.code ?? null, detail: decided.detail ?? null, trail, council: decided.council ?? null });
|
|
6649
|
+
}
|
|
6650
|
+
if (action === 'fleet-list') {
|
|
6651
|
+
return JSON.stringify({ ok: true, action, ...fleetCouncil.list() });
|
|
6652
|
+
}
|
|
6653
|
+
const read = fleetCouncil.state({ councilId: id });
|
|
6654
|
+
return JSON.stringify({ ok: read.ok, action, council_id: id, thread_id: threadId, reason_code: read.code ?? null, council: read.council ?? null });
|
|
6655
|
+
}
|
|
6656
|
+
|
|
6477
6657
|
if (action === 'list') {
|
|
6478
6658
|
return JSON.stringify({ ok: true, action, ...councilSessions.ids(), phases: PHASES, roles: [...ROLES] });
|
|
6479
6659
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fleet memory: an append-only record log with compare-and-swap.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is for
|
|
5
|
+
*
|
|
6
|
+
* The fleet memory was read/publish only. Two machines writing meant last-writer-wins over a whole document, so a record
|
|
7
|
+
* added by one could vanish because the other had an older copy. Here the unit of change is a RECORD and the guard is a
|
|
8
|
+
* revision number: a writer says which revision it saw, and a stale one is refused with the current revision and a diff
|
|
9
|
+
* summary instead of being applied on top of something it never read.
|
|
10
|
+
*
|
|
11
|
+
* ## Refusals are values
|
|
12
|
+
*
|
|
13
|
+
* `REVISION_CONFLICT` is a normal answer, not an error: the caller re-reads, decides, and retries. Automatic retry is
|
|
14
|
+
* deliberately NOT implemented here -- retrying in the transport would turn a visible conflict into a silent overwrite.
|
|
15
|
+
*/
|
|
16
|
+
export class FleetMemoryConflict extends Error {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{now?: () => number, maxRecords?: number}} [options]
|
|
20
|
+
*/
|
|
21
|
+
export function createFleetMemory(options = {}) {
|
|
22
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
23
|
+
const maxRecords = Number.isInteger(options.maxRecords) && options.maxRecords > 0 ? options.maxRecords : 10_000;
|
|
24
|
+
/** doc -> { revision, records: [{ at_ms, author_machine_id, text }] } */
|
|
25
|
+
const docs = new Map();
|
|
26
|
+
|
|
27
|
+
const stateOf = (doc) => docs.get(doc) ?? { revision: 0, records: [] };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} doc
|
|
31
|
+
* @returns {{ok: true, revision: number, records: object[], count: number}}
|
|
32
|
+
*/
|
|
33
|
+
function get(doc) {
|
|
34
|
+
const state = stateOf(doc);
|
|
35
|
+
return { ok: true, revision: state.revision, records: state.records.map((r) => ({ ...r })), count: state.records.length };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Append records, guarded by the revision the caller saw.
|
|
40
|
+
*
|
|
41
|
+
* @param {{doc: string, if_revision?: number|null, author_machine_id: string, records: string[]}} input
|
|
42
|
+
* @returns {{ok: true, revision: number, appended: number} |
|
|
43
|
+
* {ok: false, code: string, detail?: object}}
|
|
44
|
+
*/
|
|
45
|
+
function append(input) {
|
|
46
|
+
const doc = typeof input?.doc === 'string' && input.doc.trim() !== '' ? input.doc.trim() : null;
|
|
47
|
+
if (doc === null) return { ok: false, code: 'BAD_DOC' };
|
|
48
|
+
const author = typeof input?.author_machine_id === 'string' && input.author_machine_id !== '' ? input.author_machine_id : null;
|
|
49
|
+
if (author === null) return { ok: false, code: 'BAD_AUTHOR' };
|
|
50
|
+
const incoming = Array.isArray(input?.records) ? input.records : null;
|
|
51
|
+
if (incoming === null) return { ok: false, code: 'NO_RECORDS' };
|
|
52
|
+
const texts = incoming.filter((r) => typeof r === 'string' && r.trim() !== '');
|
|
53
|
+
if (texts.length === 0) return { ok: false, code: 'NO_RECORDS' };
|
|
54
|
+
if (texts.length !== incoming.length) return { ok: false, code: 'BAD_RECORD' };
|
|
55
|
+
|
|
56
|
+
const state = stateOf(doc);
|
|
57
|
+
const seen = Number.isInteger(input?.if_revision) ? input.if_revision : null;
|
|
58
|
+
if (seen !== null && seen !== state.revision) {
|
|
59
|
+
// The diff is what the caller needs to decide: how far ahead the document is, and what it has not read yet.
|
|
60
|
+
const missing = state.records.slice(Math.max(0, seen < 0 ? 0 : seen));
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
code: 'REVISION_CONFLICT',
|
|
64
|
+
detail: {
|
|
65
|
+
if_revision: seen,
|
|
66
|
+
revision: state.revision,
|
|
67
|
+
behind_by: state.revision - seen,
|
|
68
|
+
unread_records: missing.length,
|
|
69
|
+
sample: missing.slice(0, 3).map((r) => ({ author: r.author_machine_id, text: r.text.slice(0, 80) })),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (state.records.length + texts.length > maxRecords) {
|
|
74
|
+
return { ok: false, code: 'MEMORY_FULL', detail: { count: state.records.length, maxRecords } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const at = now();
|
|
78
|
+
const added = texts.map((text) => ({ at_ms: at, author_machine_id: author, text }));
|
|
79
|
+
const next = { revision: state.revision + 1, records: [...state.records, ...added] };
|
|
80
|
+
docs.set(doc, next);
|
|
81
|
+
return { ok: true, revision: next.revision, appended: added.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { get, append, _docs: docs };
|
|
85
|
+
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
RabbitState,
|
|
23
23
|
rfc3339,
|
|
24
24
|
} from './state.mjs';
|
|
25
|
-
import { createFleetMessages } from './fleet-messages.mjs';
|
|
25
|
+
import { createFleetMessages } from './fleet-messages.mjs';
|
|
26
|
+
import { createFleetMemory } from './fleet-memory.mjs';
|
|
26
27
|
import { createJoinChallenges } from './join-challenge.mjs';
|
|
27
28
|
import { createAdmission } from './admission.mjs';
|
|
28
29
|
import { Persistence } from './persistence.mjs';
|
|
@@ -1045,6 +1046,7 @@ export class RelayServer {
|
|
|
1045
1046
|
// speak as another. The refusal codes from the store are passed through unchanged (FLEET_QUOTA_EXCEEDED included)
|
|
1046
1047
|
// because a caller that cannot tell "too big" from "over quota" from "not text" cannot tell the operator what to do.
|
|
1047
1048
|
this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
|
|
1049
|
+
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1048
1050
|
|
|
1049
1051
|
if (method === 'POST' && path === '/v1/fleet/messages') {
|
|
1050
1052
|
const body = takeJson();
|
|
@@ -1115,6 +1117,37 @@ export class RelayServer {
|
|
|
1115
1117
|
});
|
|
1116
1118
|
}
|
|
1117
1119
|
|
|
1120
|
+
// v1.2.2: shared memory writes. Compare-and-swap on a revision, because two machines appending to one document is
|
|
1121
|
+
// otherwise last-writer-wins over the whole thing -- a record one machine added would vanish because the other had an
|
|
1122
|
+
// older copy. A stale writer gets 409 and the records it has not read; it is NOT retried here, because retrying in
|
|
1123
|
+
// the transport turns a visible conflict into a silent overwrite.
|
|
1124
|
+
if (path.startsWith('/v1/fleet/memory/')) {
|
|
1125
|
+
const doc = decodeURIComponent(path.slice('/v1/fleet/memory/'.length));
|
|
1126
|
+
if (doc === '') throw new ProtocolError('BAD_REQUEST', 'memory doc is required', { method, path });
|
|
1127
|
+
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1128
|
+
if (method === 'GET') {
|
|
1129
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...this.fleetMemory.get(doc) });
|
|
1130
|
+
}
|
|
1131
|
+
if (method === 'PUT') {
|
|
1132
|
+
const body = takeJson();
|
|
1133
|
+
const appended = this.fleetMemory.append({
|
|
1134
|
+
doc,
|
|
1135
|
+
if_revision: Number.isInteger(body.if_revision) ? body.if_revision : null,
|
|
1136
|
+
author_machine_id: device.machine_id,
|
|
1137
|
+
records: Array.isArray(body.records) ? body.records : (typeof body.text === 'string' ? [body.text] : []),
|
|
1138
|
+
});
|
|
1139
|
+
if (appended.ok !== true) {
|
|
1140
|
+
const status = appended.code === 'REVISION_CONFLICT' ? 409 : 400;
|
|
1141
|
+
return sendJson(res, status, {
|
|
1142
|
+
protocol_version: PROTOCOL_VERSION, ok: false,
|
|
1143
|
+
error: { code: appended.code }, detail: appended.detail ?? null,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...appended });
|
|
1147
|
+
}
|
|
1148
|
+
throw new ProtocolError('BAD_REQUEST', 'memory accepts GET or PUT', { method, path });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1118
1151
|
throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
|
|
1119
1152
|
}
|
|
1120
1153
|
|