@twinsearth/w2m-dsh-plugin 1.2.5 → 1.2.7

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.
@@ -336,6 +336,37 @@ export function rawPrivateOf(privateKey) {
336
336
  * stored: the public half is derived, which is what makes a hand-edited file unable to publish a
337
337
  * public key that does not match its private key.
338
338
  */
339
+ /**
340
+ * v1.2.6: the Ed25519 key this machine signs envelopes with.
341
+ *
342
+ * ## Why derived rather than a second file
343
+ *
344
+ * `DeviceKey` is X25519: it does key agreement for the p2p handshake and cannot sign (the runtime refuses it outright). A second
345
+ * on-disk key would be a second thing to back up, rotate and lose. The device's 32-byte private scalar is a perfectly good
346
+ * Ed25519 seed, so the signing key is derived from it: one secret to protect, and a machine that re-pairs cannot end up with a
347
+ * signature key nobody can verify.
348
+ *
349
+ * The public half travels INSIDE the signature so a reader can verify the tuple without knowing this machine beforehand. Binding
350
+ * that key to the relay's device record is the remaining step for non-repudiation across machines -- recorded in the roadmap.
351
+ *
352
+ * @param {DeviceKey} deviceKey
353
+ * @returns {{privateKey: import('node:crypto').KeyObject, publicKeyBase64: string}|null}
354
+ */
355
+ export function deriveSigningKey(deviceKey) {
356
+ if (!(deviceKey instanceof DeviceKey)) return null;
357
+ try {
358
+ const seed = rawPrivateOf(deviceKey.privateKey);
359
+ if (seed.length !== 32) return null;
360
+ const pkcs8 = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]);
361
+ const privateKey = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
362
+ const publicKey = createPublicKey(privateKey);
363
+ const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32);
364
+ return { privateKey, publicKeyBase64: raw.toString('base64') };
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
339
370
  export class DeviceKey {
340
371
  /**
341
372
  * @param {object} options
@@ -1,147 +1,153 @@
1
- /**
2
- * The dependency graph of a fleet's tasks, as pure functions.
3
- *
4
- * ## Why this is separate from the relay's own validation
5
- *
6
- * The relay refuses a graph it cannot store (unknown prerequisite, cycle, a self-edge), but that is one call at one moment.
7
- * A reader looking at a task board asks a different question: of the tasks I can see, which are READY, which are WAITING on
8
- * something that is still running, and which are BLOCKED because a prerequisite failed or was cancelled? That question
9
- * depends on the tasks' current states, so it is answered here, over whatever list the reader has.
10
- *
11
- * ## The rule that makes "blocked" honest
12
- *
13
- * A prerequisite that is missing from the visible list is treated as WAITING, never as ready: the list may be truncated by a
14
- * limit, and inferring "no dependency" from "not in this page" is how a board would show work as available that is not.
15
- */
16
- export const TASK_GRAPH_REFUSAL = Object.freeze({
17
- UNKNOWN_NODE: 'GRAPH_UNKNOWN_NODE',
18
- CYCLE: 'GRAPH_CYCLE',
19
- BAD_NODE: 'GRAPH_BAD_NODE',
20
- });
21
-
22
- const DONE = new Set(['completed', 'done']);
23
- const FAILED = new Set(['failed', 'cancelled', 'refused']);
24
- // States that mean the task is already in flight. It is not 'ready': ready means work that can be STARTED.
25
- const ACTIVE = new Set(['running', 'queued', 'offered', 'preparing', 'finalizing']);
26
-
27
- /**
28
- * @param {{nodes?: Array<{task_id?: string, depends_on?: string[]}>}} input
29
- * @returns {{ok: true, nodes: Map<string, string[]>} | {ok: false, code: string, detail: object}}
30
- */
31
- export function buildGraph({ nodes = [] } = {}) {
32
- if (!Array.isArray(nodes)) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: {} };
33
- const map = new Map();
34
- for (const node of nodes) {
35
- const id = typeof node?.task_id === 'string' && node.task_id !== '' ? node.task_id : null;
36
- if (id === null) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: { node } };
37
- const deps = Array.isArray(node.depends_on) ? node.depends_on.filter((dep) => typeof dep === 'string' && dep !== '') : [];
38
- map.set(id, deps);
39
- }
40
- return { ok: true, nodes: map };
41
- }
42
-
43
- /**
44
- * A stable topological order, or the cycle that prevents one.
45
- *
46
- * Ties are broken by id so the same graph renders in the same order on every machine -- a board that reshuffles between two
47
- * reads looks like it is changing.
48
- *
49
- * @param {Map<string, string[]>} graph
50
- * @returns {{ok: true, order: string[]} | {ok: false, code: string, detail: {cycle: string[]}}}
51
- */
52
- export function topoOrder(graph) {
53
- const indegree = new Map();
54
- const dependents = new Map();
55
- for (const id of graph.keys()) {
56
- indegree.set(id, 0);
57
- dependents.set(id, []);
58
- }
59
- for (const [id, deps] of graph.entries()) {
60
- for (const dep of deps) {
61
- // A prerequisite OUTSIDE this page is not an edge in it. The relay refuses an unknown prerequisite when the task is
62
- // created, so a missing one here means the reader's list is truncated -- and treating that as a broken graph would
63
- // make an ordinary limited read report a cycle.
64
- if (!graph.has(dep)) continue;
65
- indegree.set(id, (indegree.get(id) ?? 0) + 1);
66
- if (!dependents.has(dep)) dependents.set(dep, []);
67
- dependents.get(dep).push(id);
68
- }
69
- }
70
- const ready = [...indegree.entries()].filter(([, count]) => count === 0).map(([id]) => id).sort();
71
- const order = [];
72
- while (ready.length > 0) {
73
- const id = ready.shift();
74
- order.push(id);
75
- for (const next of (dependents.get(id) ?? []).slice().sort()) {
76
- indegree.set(next, indegree.get(next) - 1);
77
- if (indegree.get(next) === 0) { ready.push(next); ready.sort(); }
78
- }
79
- }
80
- if (order.length !== graph.size) {
81
- const remaining = [...graph.keys()].filter((id) => !order.includes(id));
82
- return { ok: false, code: TASK_GRAPH_REFUSAL.CYCLE, detail: { cycle: remaining } };
83
- }
84
- return { ok: true, order };
85
- }
86
-
87
- /**
88
- * Classify one task against the states of the tasks a reader can see.
89
- *
90
- * @param {{task_id: string, depends_on?: string[], status?: string, statuses?: Map<string, string>}} input
91
- * @returns {'ready'|'waiting'|'blocked'|'done'|'active'|'unknown'}
92
- */
93
- export function classifyTask({ task_id, depends_on = [], status = null, statuses = new Map() } = {}) {
94
- if (typeof task_id !== 'string' || task_id === '') return 'unknown';
95
- if (typeof status === 'string' && DONE.has(status)) return 'done';
96
- if (typeof status === 'string' && FAILED.has(status)) return 'blocked';
97
- let waiting = false;
98
- for (const dep of depends_on) {
99
- const depStatus = statuses.get(dep);
100
- if (depStatus === undefined) { waiting = true; continue; }
101
- if (FAILED.has(depStatus)) return 'blocked';
102
- if (!DONE.has(depStatus)) waiting = true;
103
- }
104
- if (waiting) return 'waiting';
105
- // In flight ONLY once its prerequisites are satisfied: a queued task behind a failed prerequisite is blocked, not active.
106
- if (typeof status === 'string' && ACTIVE.has(status)) return 'active';
107
- return 'ready';
108
- }
109
-
110
- /**
111
- * The whole board, in one pass: every visible task classified, and the readies in dependency order.
112
- *
113
- * @param {{tasks?: Array<{task_id?: string, depends_on?: string[], status?: string}>}} input
114
- * @returns {{ok: true, by_task: Record<string, string>, ready: string[], waiting: string[], blocked: string[], done: string[], active: string[], order: string[]} |
115
- * {ok: false, code: string, detail: object}}
116
- */
117
- export function buildBoard({ tasks = [] } = {}) {
118
- const built = buildGraph({ nodes: tasks });
119
- if (built.ok !== true) return built;
120
- const statuses = new Map();
121
- for (const task of tasks) {
122
- const id = typeof task?.task_id === 'string' ? task.task_id : null;
123
- if (id !== null) statuses.set(id, typeof task?.status === 'string' ? task.status : 'unknown');
124
- }
125
- const byTask = {};
126
- const buckets = { ready: [], waiting: [], blocked: [], done: [], active: [] };
127
- for (const [id, deps] of built.nodes.entries()) {
128
- const kind = classifyTask({ task_id: id, depends_on: deps, status: statuses.get(id), statuses });
129
- byTask[id] = kind;
130
- if (kind === 'done') buckets.done.push(id);
131
- else if (kind === 'active') buckets.active.push(id);
132
- else if (kind === 'blocked') buckets.blocked.push(id);
133
- else if (kind === 'waiting') buckets.waiting.push(id);
134
- else buckets.ready.push(id);
135
- }
136
- const order = topoOrder(built.nodes);
137
- return {
138
- ok: true,
139
- by_task: byTask,
140
- ready: buckets.ready.sort(),
141
- waiting: buckets.waiting.sort(),
142
- blocked: buckets.blocked.sort(),
143
- done: buckets.done.sort(),
144
- active: buckets.active.sort(),
145
- order: order.ok === true ? order.order : [],
146
- };
147
- }
1
+ /**
2
+ * The dependency graph of a fleet's tasks, as pure functions.
3
+ *
4
+ * ## Why this is separate from the relay's own validation
5
+ *
6
+ * The relay refuses a graph it cannot store (unknown prerequisite, cycle, a self-edge), but that is one call at one moment.
7
+ * A reader looking at a task board asks a different question: of the tasks I can see, which are READY, which are WAITING on
8
+ * something that is still running, and which are BLOCKED because a prerequisite failed or was cancelled? That question
9
+ * depends on the tasks' current states, so it is answered here, over whatever list the reader has.
10
+ *
11
+ * ## The rule that makes "blocked" honest
12
+ *
13
+ * A prerequisite that is missing from the visible list is treated as WAITING, never as ready: the list may be truncated by a
14
+ * limit, and inferring "no dependency" from "not in this page" is how a board would show work as available that is not.
15
+ */
16
+ export const TASK_GRAPH_REFUSAL = Object.freeze({
17
+ UNKNOWN_NODE: 'GRAPH_UNKNOWN_NODE',
18
+ CYCLE: 'GRAPH_CYCLE',
19
+ BAD_NODE: 'GRAPH_BAD_NODE',
20
+ });
21
+
22
+ const DONE = new Set(['completed', 'done']);
23
+ const FAILED = new Set(['failed', 'cancelled', 'refused']);
24
+ // States that mean the task is already in flight. It is not 'ready': ready means work that can be STARTED.
25
+ const ACTIVE = new Set(['running', 'queued', 'offered', 'preparing', 'finalizing', 'active']);
26
+ /**
27
+ * v1.2.7: the relay's scheduler reports 'waiting' for a task whose prerequisites are unsettled. Reading that as "ready" was the
28
+ * exact board/scheduler disagreement this set closes.
29
+ */
30
+ const WAITING = new Set(['waiting']);
31
+
32
+ /**
33
+ * @param {{nodes?: Array<{task_id?: string, depends_on?: string[]}>}} input
34
+ * @returns {{ok: true, nodes: Map<string, string[]>} | {ok: false, code: string, detail: object}}
35
+ */
36
+ export function buildGraph({ nodes = [] } = {}) {
37
+ if (!Array.isArray(nodes)) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: {} };
38
+ const map = new Map();
39
+ for (const node of nodes) {
40
+ const id = typeof node?.task_id === 'string' && node.task_id !== '' ? node.task_id : null;
41
+ if (id === null) return { ok: false, code: TASK_GRAPH_REFUSAL.BAD_NODE, detail: { node } };
42
+ const deps = Array.isArray(node.depends_on) ? node.depends_on.filter((dep) => typeof dep === 'string' && dep !== '') : [];
43
+ map.set(id, deps);
44
+ }
45
+ return { ok: true, nodes: map };
46
+ }
47
+
48
+ /**
49
+ * A stable topological order, or the cycle that prevents one.
50
+ *
51
+ * Ties are broken by id so the same graph renders in the same order on every machine -- a board that reshuffles between two
52
+ * reads looks like it is changing.
53
+ *
54
+ * @param {Map<string, string[]>} graph
55
+ * @returns {{ok: true, order: string[]} | {ok: false, code: string, detail: {cycle: string[]}}}
56
+ */
57
+ export function topoOrder(graph) {
58
+ const indegree = new Map();
59
+ const dependents = new Map();
60
+ for (const id of graph.keys()) {
61
+ indegree.set(id, 0);
62
+ dependents.set(id, []);
63
+ }
64
+ for (const [id, deps] of graph.entries()) {
65
+ for (const dep of deps) {
66
+ // A prerequisite OUTSIDE this page is not an edge in it. The relay refuses an unknown prerequisite when the task is
67
+ // created, so a missing one here means the reader's list is truncated -- and treating that as a broken graph would
68
+ // make an ordinary limited read report a cycle.
69
+ if (!graph.has(dep)) continue;
70
+ indegree.set(id, (indegree.get(id) ?? 0) + 1);
71
+ if (!dependents.has(dep)) dependents.set(dep, []);
72
+ dependents.get(dep).push(id);
73
+ }
74
+ }
75
+ const ready = [...indegree.entries()].filter(([, count]) => count === 0).map(([id]) => id).sort();
76
+ const order = [];
77
+ while (ready.length > 0) {
78
+ const id = ready.shift();
79
+ order.push(id);
80
+ for (const next of (dependents.get(id) ?? []).slice().sort()) {
81
+ indegree.set(next, indegree.get(next) - 1);
82
+ if (indegree.get(next) === 0) { ready.push(next); ready.sort(); }
83
+ }
84
+ }
85
+ if (order.length !== graph.size) {
86
+ const remaining = [...graph.keys()].filter((id) => !order.includes(id));
87
+ return { ok: false, code: TASK_GRAPH_REFUSAL.CYCLE, detail: { cycle: remaining } };
88
+ }
89
+ return { ok: true, order };
90
+ }
91
+
92
+ /**
93
+ * Classify one task against the states of the tasks a reader can see.
94
+ *
95
+ * @param {{task_id: string, depends_on?: string[], status?: string, statuses?: Map<string, string>}} input
96
+ * @returns {'ready'|'waiting'|'blocked'|'done'|'active'|'unknown'}
97
+ */
98
+ export function classifyTask({ task_id, depends_on = [], status = null, statuses = new Map() } = {}) {
99
+ if (typeof task_id !== 'string' || task_id === '') return 'unknown';
100
+ if (typeof status === 'string' && DONE.has(status)) return 'done';
101
+ if (typeof status === 'string' && FAILED.has(status)) return 'blocked';
102
+ let waiting = false;
103
+ for (const dep of depends_on) {
104
+ const depStatus = statuses.get(dep);
105
+ if (depStatus === undefined) { waiting = true; continue; }
106
+ if (FAILED.has(depStatus)) return 'blocked';
107
+ if (!DONE.has(depStatus)) waiting = true;
108
+ }
109
+ if (waiting) return 'waiting';
110
+ // In flight ONLY once its prerequisites are satisfied: a queued task behind a failed prerequisite is blocked, not active.
111
+ if (typeof status === 'string' && WAITING.has(status)) return 'waiting';
112
+ if (typeof status === 'string' && ACTIVE.has(status)) return 'active';
113
+ return 'ready';
114
+ }
115
+
116
+ /**
117
+ * The whole board, in one pass: every visible task classified, and the readies in dependency order.
118
+ *
119
+ * @param {{tasks?: Array<{task_id?: string, depends_on?: string[], status?: string}>}} input
120
+ * @returns {{ok: true, by_task: Record<string, string>, ready: string[], waiting: string[], blocked: string[], done: string[], active: string[], order: string[]} |
121
+ * {ok: false, code: string, detail: object}}
122
+ */
123
+ export function buildBoard({ tasks = [] } = {}) {
124
+ const built = buildGraph({ nodes: tasks });
125
+ if (built.ok !== true) return built;
126
+ const statuses = new Map();
127
+ for (const task of tasks) {
128
+ const id = typeof task?.task_id === 'string' ? task.task_id : null;
129
+ if (id !== null) statuses.set(id, typeof task?.status === 'string' ? task.status : 'unknown');
130
+ }
131
+ const byTask = {};
132
+ const buckets = { ready: [], waiting: [], blocked: [], done: [], active: [] };
133
+ for (const [id, deps] of built.nodes.entries()) {
134
+ const kind = classifyTask({ task_id: id, depends_on: deps, status: statuses.get(id), statuses });
135
+ byTask[id] = kind;
136
+ if (kind === 'done') buckets.done.push(id);
137
+ else if (kind === 'active') buckets.active.push(id);
138
+ else if (kind === 'blocked') buckets.blocked.push(id);
139
+ else if (kind === 'waiting') buckets.waiting.push(id);
140
+ else buckets.ready.push(id);
141
+ }
142
+ const order = topoOrder(built.nodes);
143
+ return {
144
+ ok: true,
145
+ by_task: byTask,
146
+ ready: buckets.ready.sort(),
147
+ waiting: buckets.waiting.sort(),
148
+ blocked: buckets.blocked.sort(),
149
+ done: buckets.done.sort(),
150
+ active: buckets.active.sort(),
151
+ order: order.ok === true ? order.order : [],
152
+ };
153
+ }