@twinsearth/w2m-dsh-plugin 1.2.0 → 1.2.2

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.
@@ -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
+ }
@@ -88,25 +88,46 @@ export function createFleetMessages(options = {}) {
88
88
  const text = input.text;
89
89
  if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
90
90
  // Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
91
- // Control characters are the thing being rejected here, so the rule that forbids them in a pattern
92
- // does not apply -- the escape IS the contract.
93
- // eslint-disable-next-line no-control-regex
91
+ // eslint-disable-next-line no-control-regex
94
92
  if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
95
93
  const bytes = Buffer.byteLength(text, 'utf8');
96
94
  if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
97
95
 
96
+ // Addressing: 'all' is the group, a machine id is one recipient. ONE row either way -- N copies for N recipients
97
+ // would multiply the byte budget by the fleet size for no extra meaning; visibility is a filter at read time.
98
+ const to = typeof input?.to === 'string' && input.to.trim() !== '' ? input.to.trim() : 'all';
99
+ if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
100
+ // Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
101
+ // exchange inside one thread. Plain fields, validated rather than trusted.
102
+ const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
103
+ ? input.thread_id.trim().slice(0, 64)
104
+ : null;
105
+ const replyTo = typeof input?.reply_to === 'string' && input.reply_to.trim() !== '' ? input.reply_to.trim() : null;
106
+
98
107
  const before = usage();
99
108
  const limit = limitBytes();
100
109
  if (before.used_24h_bytes + bytes > limit) {
101
110
  return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
102
111
  }
103
112
 
113
+ // A reply must point at something that exists in the SAME session: a dangling reply_to makes a thread unreadable.
114
+ const existing = sessions.get(sessionId) ?? [];
115
+ if (replyTo !== null && !existing.some((m) => m.message_id === replyTo)) {
116
+ return { ok: false, code: 'REPLY_TARGET_MISSING', detail: { reply_to: replyTo } };
117
+ }
118
+ const threadOf = threadId ?? (replyTo !== null ? (existing.find((m) => m.message_id === replyTo)?.thread_id ?? null) : null);
119
+ const turn = existing.filter((m) => (threadOf === null ? m.thread_id === null : m.thread_id === threadOf)).length + 1;
120
+
104
121
  seq += 1;
105
122
  const message = {
106
123
  message_id: 'msg-' + String(seq).padStart(6, '0'),
107
124
  session_id: sessionId,
108
125
  fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
109
126
  machine_id: machineId,
127
+ to,
128
+ thread_id: threadOf,
129
+ reply_to: replyTo,
130
+ turn,
110
131
  text,
111
132
  at_ms: now(),
112
133
  bytes,
@@ -122,9 +143,16 @@ export function createFleetMessages(options = {}) {
122
143
  }
123
144
 
124
145
  /** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
125
- function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50 } = {}) {
146
+ function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50, for_machine_id: forMachine = null,
147
+ thread_id: threadId = null, to: toFilter = null } = {}) {
126
148
  const rows = sessions.get(sessionId) ?? [];
127
- const filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
149
+ let filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
150
+ // A reader sees the group and what was addressed to it, never another machine's private message.
151
+ if (typeof forMachine === 'string' && forMachine !== '') {
152
+ filtered = filtered.filter((m) => (m.to ?? 'all') === 'all' || m.to === forMachine || m.machine_id === forMachine);
153
+ }
154
+ if (typeof threadId === 'string' && threadId !== '') filtered = filtered.filter((m) => m.thread_id === threadId);
155
+ if (typeof toFilter === 'string' && toFilter !== '') filtered = filtered.filter((m) => (m.to ?? 'all') === toFilter);
128
156
  const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
129
157
  return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
130
158
  }
@@ -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();
@@ -1052,6 +1054,9 @@ export class RelayServer {
1052
1054
  session_id: body.session_id,
1053
1055
  fleet_id: typeof body.fleet_id === 'string' ? body.fleet_id : null,
1054
1056
  machine_id: device.machine_id,
1057
+ to: body.to,
1058
+ thread_id: body.thread_id,
1059
+ reply_to: body.reply_to,
1055
1060
  text: body.text,
1056
1061
  });
1057
1062
  if (posted.ok !== true) {
@@ -1098,6 +1103,11 @@ export class RelayServer {
1098
1103
  session_id: url.searchParams.get('session_id') ?? '',
1099
1104
  since_ms: sinceRaw === null ? null : Number(sinceRaw),
1100
1105
  limit: limitRaw === null ? 50 : Number(limitRaw),
1106
+ // Addressing/correlation filters. `for_me=1` is answered from the authenticated device identity, never from a
1107
+ // caller-supplied machine id: a reader must not be able to ask for another machine's private messages.
1108
+ for_machine_id: url.searchParams.get('for_me') === '1' ? device.machine_id : null,
1109
+ thread_id: url.searchParams.get('thread_id'),
1110
+ to: url.searchParams.get('to'),
1101
1111
  });
1102
1112
  return sendJson(res, 200, {
1103
1113
  protocol_version: PROTOCOL_VERSION,
@@ -1107,6 +1117,37 @@ export class RelayServer {
1107
1117
  });
1108
1118
  }
1109
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
+
1110
1151
  throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
1111
1152
  }
1112
1153