@twinsearth/w2m-dsh-plugin 1.2.6 → 1.3.0
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 +30 -29
- package/.claude-plugin/plugin.json +29 -29
- package/CHANGELOG.md +197 -0
- package/README.md +3 -3
- package/codex/mcp-servers.toml +1 -1
- package/docs/DATA-PLANE-COLLABORATION.md +82 -0
- package/lib/client.js +942 -941
- package/package.json +236 -235
- package/plugin.json +14 -14
- package/src/agent/agent.mjs +77 -1
- package/src/plugin/merge-hint.mjs +35 -0
- package/src/plugin/task-graph.mjs +153 -147
- package/src/plugin/tools.mjs +204 -19
- package/src/relay/fleet-messages.mjs +258 -241
- package/src/relay/report.mjs +11 -0
- package/src/relay/server.mjs +16 -1
- package/src/relay/state.mjs +135 -0
- package/src/util/dataflow.mjs +24 -1
- package/src/util/reduce-suggestion.mjs +107 -0
package/src/agent/agent.mjs
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
import { Readable } from 'node:stream';
|
|
25
25
|
import { access, constants as fsConstants } from 'node:fs/promises';
|
|
26
|
-
import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { existsSync, mkdirSync, mkdtempSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
28
29
|
import { randomUUID , sign as signBytes } from 'node:crypto';
|
|
29
30
|
|
|
30
31
|
import {
|
|
@@ -3027,6 +3028,58 @@ export function createAgent(options) {
|
|
|
3027
3028
|
// never allowed to ask for, and the plugin's pre-flight refuses the dispatch as a whole for the
|
|
3028
3029
|
// same reason. The refusal names the stage index, because "this chain is not allowed" is not
|
|
3029
3030
|
// actionable without knowing which stage.
|
|
3031
|
+
/**
|
|
3032
|
+
* v1.2.10: a reduce task carries the artifacts it consumes, and the machine fetches them BEFORE the reduce command runs.
|
|
3033
|
+
*
|
|
3034
|
+
* The list is the caller's completeness decision (the relay validated its shape, never its truth), and the bytes are
|
|
3035
|
+
* content-addressed, so the fetch verifies nothing less than the hash already did. A failed fetch refuses the whole task:
|
|
3036
|
+
* a reduce that ran on half the inputs would produce a confident wrong answer, which is the failure this feature exists to
|
|
3037
|
+
* make impossible.
|
|
3038
|
+
*/
|
|
3039
|
+
let reduceEnv = null;
|
|
3040
|
+
if (Array.isArray(offer.reduce_inputs) && offer.reduce_inputs.length > 0) {
|
|
3041
|
+
try {
|
|
3042
|
+
const inputDir = mkdtempSync(join(tmpdir(), 'w2m-reduce-'));
|
|
3043
|
+
const manifest = [];
|
|
3044
|
+
for (const artifact of offer.reduce_inputs) {
|
|
3045
|
+
const response = await fetch(rabbitUrl + '/v1/fleet/blobs/' + encodeURIComponent(artifact.sha256), {
|
|
3046
|
+
headers: token() ? { authorization: 'Bearer ' + token() } : {},
|
|
3047
|
+
});
|
|
3048
|
+
if (response.status !== 200) {
|
|
3049
|
+
throw new Error('blob ' + artifact.sha256 + ' -> HTTP ' + response.status);
|
|
3050
|
+
}
|
|
3051
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
3052
|
+
const file = join(inputDir, String(artifact.machine_id).replace(/[^A-Za-z0-9._-]/g, '_') + '.bin');
|
|
3053
|
+
writeFileSync(file, body);
|
|
3054
|
+
manifest.push({ machine_id: artifact.machine_id, sha256: artifact.sha256, bytes: body.length, file });
|
|
3055
|
+
}
|
|
3056
|
+
const manifestPath = join(inputDir, 'manifest.json');
|
|
3057
|
+
writeFileSync(manifestPath, JSON.stringify({ reduce_of: offer.reduce_of ?? null, inputs: manifest }, null, 2));
|
|
3058
|
+
reduceEnv = {
|
|
3059
|
+
W2M_REDUCE_INPUTS_DIR: inputDir,
|
|
3060
|
+
W2M_REDUCE_MANIFEST: manifestPath,
|
|
3061
|
+
W2M_REDUCE_OF: String(offer.reduce_of ?? ''),
|
|
3062
|
+
};
|
|
3063
|
+
} catch (error) {
|
|
3064
|
+
log('warn', offer.task_id + ': reduce input fetch failed', { error: error?.message ?? String(error) });
|
|
3065
|
+
await finish({
|
|
3066
|
+
offer,
|
|
3067
|
+
cwdRel,
|
|
3068
|
+
commandArgv,
|
|
3069
|
+
anchorsBefore,
|
|
3070
|
+
anchorsAfter: null,
|
|
3071
|
+
execution: null,
|
|
3072
|
+
status: 'refused',
|
|
3073
|
+
refusalReason: 'REDUCE_INPUT_FETCH_FAILED',
|
|
3074
|
+
warnings,
|
|
3075
|
+
startedAt,
|
|
3076
|
+
endedAt: new Date().toISOString(),
|
|
3077
|
+
protocolViolations: ['reduce inputs could not be fetched: ' + (error?.message ?? String(error))],
|
|
3078
|
+
});
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3030
3083
|
for (const stage of plannedStages) {
|
|
3031
3084
|
const stageAllowed = matchAllowedCommand(stage.command_argv, allowedCommands);
|
|
3032
3085
|
if (stageAllowed.allowed) continue;
|
|
@@ -3120,6 +3173,7 @@ export function createAgent(options) {
|
|
|
3120
3173
|
W2M_MODE: String(offer.mode ?? ''),
|
|
3121
3174
|
W2M_MACHINE_ID: String(offer.machine_id ?? ''),
|
|
3122
3175
|
W2M_MACHINE_NAME: String(offer.machine_name ?? ''),
|
|
3176
|
+
...(reduceEnv ?? {}),
|
|
3123
3177
|
};
|
|
3124
3178
|
const ran = await runArgv(stage.command_argv, {
|
|
3125
3179
|
cwd: stageCwdOutcome.cwd,
|
|
@@ -3215,6 +3269,28 @@ export function createAgent(options) {
|
|
|
3215
3269
|
}
|
|
3216
3270
|
}
|
|
3217
3271
|
|
|
3272
|
+
/**
|
|
3273
|
+
* v1.2.7: a plain command's large stdout is a stage artifact too.
|
|
3274
|
+
*
|
|
3275
|
+
* The blob path used to live only in the pipeline loop, so a split/mapreduce map result was always inlined and a
|
|
3276
|
+
* reduce plan could never see the data it reduces -- the missing-shard check was unreachable from a real task. A
|
|
3277
|
+
* command that prints more than the inline cap now uploads its stdout exactly as a stage does. Best effort here: a
|
|
3278
|
+
* failed upload must not turn a successful command into a failure, so the envelope stays inline and says why.
|
|
3279
|
+
*/
|
|
3280
|
+
if (isPipeline !== true && execution !== null && lastBlobSha256 === null) {
|
|
3281
|
+
const plainPlan = planStageOutput({ machineId: identity.machine_id, stageIndex: 0, stdoutBytes: execution.stdout_bytes });
|
|
3282
|
+
if (plainPlan.ok === true && plainPlan.mode === 'blob') {
|
|
3283
|
+
if (execution.truncated_stdout === true && !warnings.includes('STDOUT_TRUNCATED')) warnings.push('STDOUT_TRUNCATED');
|
|
3284
|
+
const uploaded = await putBlobBytes(execution.stdout_sha256, execution.stdout);
|
|
3285
|
+
if (uploaded.ok === true) {
|
|
3286
|
+
lastBlobSha256 = uploaded.sha256;
|
|
3287
|
+
} else {
|
|
3288
|
+
log('warn', offer.task_id + ': plain stdout blob upload failed', { code: uploaded.code });
|
|
3289
|
+
warnings.push('BLOB_UPLOAD_FAILED');
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3218
3294
|
heartbeat.setPhase('finalizing', 90);
|
|
3219
3295
|
if (execution === null) {
|
|
3220
3296
|
// Nothing ran at all (a refused first stage, or a budget already spent). `execution` stays
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge hint: what a reader of a shared memory document should do about a conflict.
|
|
3
|
+
*
|
|
4
|
+
* A 409 from the memory CAS already carries `behind_by`, `unread_records` and a sample. The reader still has to decide what
|
|
5
|
+
* "behind by 3" means for the thing they are about to write. This turns those numbers into a named next step -- re-read, compare,
|
|
6
|
+
* then append with the current revision -- and never merges automatically: an automatic merge would write records the caller has
|
|
7
|
+
* not seen. Pure: it reads the refusal the relay already sent.
|
|
8
|
+
*/
|
|
9
|
+
export const MERGE_UP_TO_DATE = 'up-to-date';
|
|
10
|
+
export const MERGE_RE_READ = 're-read-then-append';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{conflict?: object|null}} input `conflict` is the 409 detail: { behind_by, unread_records, sample }.
|
|
14
|
+
* @returns {{state: string, behind_by: number, unread: number, sample_count: number, next: string}}
|
|
15
|
+
*/
|
|
16
|
+
export function renderMergeHint(input = {}) {
|
|
17
|
+
const conflict = input.conflict ?? null;
|
|
18
|
+
if (conflict === null || typeof conflict !== 'object') {
|
|
19
|
+
return { state: MERGE_UP_TO_DATE, behind_by: 0, unread: 0, sample_count: 0, next: 'append normally' };
|
|
20
|
+
}
|
|
21
|
+
const behindBy = Number.isInteger(conflict.behind_by) && conflict.behind_by > 0 ? conflict.behind_by : 0;
|
|
22
|
+
const unread = Array.isArray(conflict.unread_records) ? conflict.unread_records.length
|
|
23
|
+
: (Number.isInteger(conflict.unread) && conflict.unread > 0 ? conflict.unread : 0);
|
|
24
|
+
const sample = Array.isArray(conflict.sample) ? conflict.sample : [];
|
|
25
|
+
if (behindBy === 0 && unread === 0) {
|
|
26
|
+
return { state: MERGE_UP_TO_DATE, behind_by: 0, unread: 0, sample_count: 0, next: 'append normally' };
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
state: MERGE_RE_READ,
|
|
30
|
+
behind_by: behindBy,
|
|
31
|
+
unread,
|
|
32
|
+
sample_count: sample.length,
|
|
33
|
+
next: 're-read the document, compare the records listed here, then append with the current revision -- do not merge unseen records',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -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
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
for (const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
}
|