@twinsearth/w2m-dsh-plugin 1.2.3 → 1.2.5

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,167 @@
1
+ /**
2
+ * Open questions and their answers, on disk.
3
+ *
4
+ * ## Why a file, when the answer also arrives by chat
5
+ *
6
+ * An `await` that is answered while the asking process is being restarted has to survive the restart: the question is still
7
+ * the machine's question, and the answer is still an answer. Keeping the state in memory made the outcome depend on uptime.
8
+ *
9
+ * ## The two states that are easy to get wrong
10
+ *
11
+ * - **timeout is not final.** A late answer is recorded as `answered_after_timeout` rather than overwriting the timeout or
12
+ * being dropped: the operator asked, something answered, and when it answered is a fact worth keeping.
13
+ * - **a settled question is never reopened.** `settle` on a decided ask returns the existing record with `settled: false`, so
14
+ * two replies cannot race into one slot.
15
+ *
16
+ * Records are appended as JSON lines; `compact` rewrites atomically through a temporary file, which is the only writer of the
17
+ * whole file.
18
+ */
19
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ export const ASK_STATE = Object.freeze(['open', 'answered', 'refused', 'timeout', 'answered_after_timeout']);
23
+
24
+ /** The states that are still waiting for something. */
25
+ export const OPEN_STATES = Object.freeze(['open']);
26
+
27
+ /**
28
+ * @param {{now?: () => number, maxRecords?: number}} [options]
29
+ */
30
+ export function createAskStore(options = {}) {
31
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
32
+ const maxRecords = Number.isInteger(options.maxRecords) && options.maxRecords > 0 ? options.maxRecords : 2_000;
33
+ /** ask_id -> record */
34
+ const asks = new Map();
35
+ let seq = 0;
36
+ const file = typeof options.stateDir === 'string' && options.stateDir !== ''
37
+ ? join(options.stateDir, 'ask-store.jsonl')
38
+ : null;
39
+
40
+ /**
41
+ * Load whatever a previous process wrote. A malformed line is skipped rather than fatal: an unfinished write is the
42
+ * expected shape of a crash, and losing the whole store over the last line would be worse than losing the line.
43
+ */
44
+ if (file !== null) {
45
+ try {
46
+ const text = readFileSync(file, 'utf8');
47
+ for (const line of text.split('\n')) {
48
+ if (line.trim() === '') continue;
49
+ try { const record = JSON.parse(line); if (typeof record?.ask_id === 'string') asks.set(record.ask_id, record); } catch { /* one bad line */ }
50
+ }
51
+ } catch { /* no store yet */ }
52
+ }
53
+
54
+ /** Rewrite the file atomically: a reader never sees half a record. */
55
+ function persist() {
56
+ if (file === null) return;
57
+ try {
58
+ mkdirSync(dirname(file), { recursive: true });
59
+ const body = exportAll().map((record) => JSON.stringify(record)).join('\n') + '\n';
60
+ const temporary = file + '.tmp';
61
+ writeFileSync(temporary, body, 'utf8');
62
+ renameSync(temporary, file);
63
+ } catch { /* the in-memory store is still correct; persistence is best effort and says so by not throwing */ }
64
+ }
65
+
66
+ function openAsk(input = {}) {
67
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId !== '' ? input.sessionId : null;
68
+ const messageId = typeof input.messageId === 'string' && input.messageId !== '' ? input.messageId : null;
69
+ if (sessionId === null || messageId === null) return { ok: false, code: 'ASK_BAD_INPUT' };
70
+ const askId = 'ask_' + (++seq) + '_' + now();
71
+ const record = {
72
+ ask_id: askId,
73
+ session_id: sessionId,
74
+ message_id: messageId,
75
+ thread_id: typeof input.threadId === 'string' ? input.threadId : null,
76
+ to: typeof input.to === 'string' ? input.to : null,
77
+ opened_at_ms: now(),
78
+ deadline_ms: now() + (Number.isFinite(input.timeoutMs) ? Math.max(1, input.timeoutMs) : 60_000),
79
+ state: 'open',
80
+ reply_message_id: null,
81
+ settled_at_ms: null,
82
+ };
83
+ asks.set(askId, record);
84
+ trim();
85
+ persist();
86
+ return { ok: true, ask: { ...record } };
87
+ }
88
+
89
+ function settle(askId, state, reply = {}) {
90
+ const record = asks.get(askId) ?? null;
91
+ if (record === null) return { ok: false, code: 'ASK_UNKNOWN', detail: { askId } };
92
+ if (!ASK_STATE.includes(state) || state === 'open') return { ok: false, code: 'ASK_BAD_STATE', detail: { state } };
93
+ if (record.state !== 'open' && record.state !== 'timeout') {
94
+ return { ok: true, ask: { ...record }, settled: false };
95
+ }
96
+ // A reply after the deadline is recorded as such: "answered late" and "answered" are different facts.
97
+ const late = record.state === 'timeout';
98
+ record.state = late ? 'answered_after_timeout' : state;
99
+ record.reply_message_id = typeof reply.messageId === 'string' ? reply.messageId : null;
100
+ record.settled_at_ms = now();
101
+ persist();
102
+ return { ok: true, ask: { ...record }, settled: true };
103
+ }
104
+
105
+ function expire(askId) {
106
+ const record = asks.get(askId) ?? null;
107
+ if (record === null) return { ok: false, code: 'ASK_UNKNOWN', detail: { askId } };
108
+ if (record.state !== 'open') return { ok: true, ask: { ...record }, expired: false };
109
+ record.state = 'timeout';
110
+ record.settled_at_ms = now();
111
+ persist();
112
+ return { ok: true, ask: { ...record }, expired: true };
113
+ }
114
+
115
+ function expireDue() {
116
+ const due = [];
117
+ for (const record of asks.values()) {
118
+ if (record.state === 'open' && record.deadline_ms <= now()) {
119
+ record.state = 'timeout';
120
+ record.settled_at_ms = now();
121
+ due.push(record.ask_id);
122
+ }
123
+ }
124
+ if (due.length > 0) persist();
125
+ return { ok: true, expired: due };
126
+ }
127
+
128
+ /** @param {{state?: string, sessionId?: string}} [filter] */
129
+ function list(filter = {}) {
130
+ const wanted = typeof filter.state === 'string' ? filter.state : null;
131
+ const session = typeof filter.sessionId === 'string' ? filter.sessionId : null;
132
+ return {
133
+ ok: true,
134
+ asks: [...asks.values()]
135
+ .filter((record) => (wanted === null || record.state === wanted) && (session === null || record.session_id === session))
136
+ .sort((a, b) => a.opened_at_ms - b.opened_at_ms)
137
+ .map((record) => ({ ...record })),
138
+ };
139
+ }
140
+
141
+ function restore(records) {
142
+ const restored = [];
143
+ for (const record of Array.isArray(records) ? records : []) {
144
+ const askId = typeof record?.ask_id === 'string' ? record.ask_id : null;
145
+ if (askId === null || !ASK_STATE.includes(record.state)) continue;
146
+ asks.set(askId, { ...record });
147
+ restored.push(askId);
148
+ }
149
+ persist();
150
+ return { ok: true, restored: restored.length };
151
+ }
152
+
153
+ /** The records that survive a restart, oldest first. */
154
+ function exportAll() {
155
+ return [...asks.values()].sort((a, b) => a.opened_at_ms - b.opened_at_ms).map((record) => ({ ...record }));
156
+ }
157
+
158
+ function trim() {
159
+ while (asks.size > maxRecords) {
160
+ const oldest = [...asks.values()].sort((a, b) => a.opened_at_ms - b.opened_at_ms)[0];
161
+ if (oldest === undefined) return;
162
+ asks.delete(oldest.ask_id);
163
+ }
164
+ }
165
+
166
+ return { openAsk, settle, expire, expireDue, list, restore, exportAll, _asks: asks };
167
+ }
@@ -47,3 +47,36 @@ export function schemaProblems(schema, name = 'tool') {
47
47
  .map(({ path }) => ({ path: path + '.additionalProperties', why: 'an object parameter must state additionalProperties: true or false' }))
48
48
  .map((problem) => ({ ...problem, path: name + '.' + problem.path }));
49
49
  }
50
+
51
+
52
+ /** Keys a parameter declaration may carry. Anything else is a typo or a parameter nested by accident. */
53
+ const PARAM_KEYS = new Set(['type', 'items', 'description', 'default', 'required', 'enum', 'properties', 'additionalProperties', 'oneOf', 'anyOf']);
54
+
55
+ /**
56
+ * Parameters nested inside another parameter's declaration.
57
+ *
58
+ * This is the shape that shipped in the 1.2.4 build: a parameter block pasted inside its predecessor. Every local schema check
59
+ * was green, and the real host refused the whole entry ("parameters.poll_ms.reducer_machine_id is not supported by the value
60
+ * schema DSL") -- because to JavaScript it is a perfectly good object.
61
+ *
62
+ * @param {Record<string, object>} parameters
63
+ * @param {string} [name]
64
+ * @returns {Array<{path: string, why: string}>}
65
+ */
66
+ export function strayParameterProblems(parameters, name = 'tool') {
67
+ const problems = [];
68
+ for (const [key, decl] of Object.entries(parameters ?? {})) {
69
+ if (decl === null || typeof decl !== 'object') continue;
70
+ for (const nested of Object.keys(decl)) {
71
+ if (PARAM_KEYS.has(nested)) continue;
72
+ const looksLikeParameter = decl[nested] !== null && typeof decl[nested] === 'object' && typeof decl[nested].type === 'string';
73
+ if (looksLikeParameter) {
74
+ problems.push({
75
+ path: name + '.parameters.' + key + '.' + nested,
76
+ why: 'a parameter declaration is nested inside another parameter; move it to the parameters object',
77
+ });
78
+ }
79
+ }
80
+ }
81
+ return problems;
82
+ }
@@ -0,0 +1,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']);
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
+ }