@twinsearth/w2m-dsh-plugin 1.2.3 → 1.2.6

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,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
+ }