@astrosheep/keiyaku 4.5.4 → 4.5.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.
- package/build/src/cli/commands/install.js +2 -1
- package/build/src/cli/commands/task-invoke.d.ts +0 -2
- package/build/src/cli/commands/task-invoke.js +2 -15
- package/build/src/cli/commands/task.js +3 -21
- package/build/src/cli/invoke.js +0 -1
- package/build/src/cli/render/task.js +1 -25
- package/build/src/runtime/proc/windows-launch.exe +0 -0
- package/build/src/task/compose.d.ts +9 -20
- package/build/src/task/compose.js +287 -124
- package/build/src/task/index.d.ts +2 -3
- package/build/src/task/index.js +2 -4
- package/build/src/task/operations.d.ts +1 -6
- package/package.json +1 -1
- package/build/src/task/compose-language.d.ts +0 -43
- package/build/src/task/compose-language.js +0 -314
- package/build/src/task/compose-parser.d.ts +0 -31
- package/build/src/task/compose-parser.js +0 -162
|
@@ -47,6 +47,7 @@ export function renderInstallHelp() {
|
|
|
47
47
|
const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
48
48
|
const ASSET_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../../integrations/marketplace");
|
|
49
49
|
const PLUGIN_ROOT = resolve(ASSET_ROOT, "plugins/keiyaku");
|
|
50
|
+
const PI_PACKAGE_SOURCE = "npm:@astrosheep/keiyaku";
|
|
50
51
|
function recipes(harness) {
|
|
51
52
|
switch (harness) {
|
|
52
53
|
case "codex":
|
|
@@ -63,7 +64,7 @@ function recipes(harness) {
|
|
|
63
64
|
case "opencode":
|
|
64
65
|
return [["opencode", "plugin", PLUGIN_ROOT, "--global", "--force"]];
|
|
65
66
|
case "pi":
|
|
66
|
-
return [["pi", "install",
|
|
67
|
+
return [["pi", "install", PI_PACKAGE_SOURCE]];
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
function diagnostic(argv, outcome) {
|
|
@@ -19,7 +19,6 @@ export type TaskWorldObservation = Readonly<{
|
|
|
19
19
|
}>;
|
|
20
20
|
type TaskInput = Readonly<{
|
|
21
21
|
world: WorldRoot | null;
|
|
22
|
-
candidate: WorldRoot | null;
|
|
23
22
|
context: Readonly<{
|
|
24
23
|
directory: string;
|
|
25
24
|
boundary: string;
|
|
@@ -31,7 +30,6 @@ type TaskInput = Readonly<{
|
|
|
31
30
|
export declare function invokeTaskFromEdge(input: Readonly<{
|
|
32
31
|
parsed: ParsedTaskCommand;
|
|
33
32
|
world: WorldRoot | null;
|
|
34
|
-
candidate: WorldRoot | null;
|
|
35
33
|
context: Readonly<{
|
|
36
34
|
directory: string;
|
|
37
35
|
boundary: string;
|
|
@@ -8,7 +8,6 @@ export async function invokeTaskFromEdge(input) {
|
|
|
8
8
|
try {
|
|
9
9
|
return await invokeTask(input.parsed, {
|
|
10
10
|
world: input.world,
|
|
11
|
-
candidate: input.candidate,
|
|
12
11
|
context: input.context,
|
|
13
12
|
establish: input.establish,
|
|
14
13
|
readStdin: input.readStdin,
|
|
@@ -294,15 +293,6 @@ function missingWorld(command) {
|
|
|
294
293
|
});
|
|
295
294
|
if (isWorldObservation(command))
|
|
296
295
|
return { kind: "absent" };
|
|
297
|
-
if (command.action === "compose" && command.flags.plan === true) {
|
|
298
|
-
return {
|
|
299
|
-
kind: "refused",
|
|
300
|
-
refusal: {
|
|
301
|
-
kind: "invalid-composition",
|
|
302
|
-
diagnostics: [{ line: 1, reason: "task world has no valid planning coordinate", token: "--plan" }],
|
|
303
|
-
},
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
296
|
if (command.action === "namespace")
|
|
307
297
|
return { kind: "accepted", value: [] };
|
|
308
298
|
if (command.action === "hold" || command.action === "done" || command.action === "drop") {
|
|
@@ -348,7 +338,6 @@ async function invokeLocalMutation(tasks, command, input, current) {
|
|
|
348
338
|
markdown: await input.readStdin(),
|
|
349
339
|
namespace: current ?? [],
|
|
350
340
|
...(input.actor === undefined ? {} : { actor: input.actor }),
|
|
351
|
-
...(command.flags.plan === true ? { plan: true } : {}),
|
|
352
341
|
});
|
|
353
342
|
default:
|
|
354
343
|
throw new Error(`task action has no invocation: ${command.action}`);
|
|
@@ -356,19 +345,17 @@ async function invokeLocalMutation(tasks, command, input, current) {
|
|
|
356
345
|
}
|
|
357
346
|
function establishesWorld(command) {
|
|
358
347
|
return (command.action === "add" ||
|
|
359
|
-
|
|
348
|
+
command.action === "compose" ||
|
|
360
349
|
(command.action === "namespace" && command.positionals.length > 0));
|
|
361
350
|
}
|
|
362
351
|
function forwardsMutation(command) {
|
|
363
352
|
return (command.action !== "show" &&
|
|
364
353
|
command.action !== "tree" &&
|
|
365
354
|
command.action !== "namespace" &&
|
|
366
|
-
!(command.action === "compose" && command.flags.plan === true) &&
|
|
367
355
|
!isWorldObservation(command));
|
|
368
356
|
}
|
|
369
357
|
export async function invokeTask(command, input) {
|
|
370
|
-
const
|
|
371
|
-
const world = input.world ?? (planOnly ? input.candidate : establishesWorld(command) ? await input.establish() : null);
|
|
358
|
+
const world = input.world ?? (establishesWorld(command) ? await input.establish() : null);
|
|
372
359
|
if (world === null)
|
|
373
360
|
return missingWorld(command);
|
|
374
361
|
const tasks = Tasks.of(world);
|
|
@@ -133,27 +133,9 @@ task add [--namespace <ns>] [--actor <actor>] -`,
|
|
|
133
133
|
compose: {
|
|
134
134
|
arity: [0, 0],
|
|
135
135
|
stdin: "compose",
|
|
136
|
-
flags: { ...COMMON, actor: "value"
|
|
137
|
-
usage: "task compose [--actor <actor>]
|
|
138
|
-
purpose: "
|
|
139
|
-
details: [
|
|
140
|
-
"nodes: + <Title> creates; @task/<id> modifies a pre-existing Task",
|
|
141
|
-
"properties: as = <alias>; pri = 0..3; parent = <ref>|empty",
|
|
142
|
-
"relations: needs|supersedes|relates with =, +=, or -=",
|
|
143
|
-
"references: @task/... is pre-existing; ^alias is new in this document",
|
|
144
|
-
"body: body = clears; body <<TOKEN reads exact content to TOKEN",
|
|
145
|
-
"TOKEN: [A-Z][A-Z0-9_]* with length 3..32",
|
|
146
|
-
"example:",
|
|
147
|
-
" ns=feature",
|
|
148
|
-
" + Parent",
|
|
149
|
-
" as = parent",
|
|
150
|
-
" pri = 1",
|
|
151
|
-
" body <<BODY",
|
|
152
|
-
" Exact body bytes.",
|
|
153
|
-
" BODY",
|
|
154
|
-
" + Child",
|
|
155
|
-
" parent = ^parent",
|
|
156
|
-
].join("\n"),
|
|
136
|
+
flags: { ...COMMON, actor: "value" },
|
|
137
|
+
usage: "task compose [--actor <actor>] -",
|
|
138
|
+
purpose: "Admit Task documents independently; partial admission has no cross-file atomicity or rollback.",
|
|
157
139
|
},
|
|
158
140
|
};
|
|
159
141
|
export function isTaskAction(value) {
|
package/build/src/cli/invoke.js
CHANGED
|
@@ -318,7 +318,6 @@ async function invokeParsed(invocation, runtime) {
|
|
|
318
318
|
return await (await import("./commands/task-invoke.js")).invokeTaskFromEdge({
|
|
319
319
|
parsed,
|
|
320
320
|
world,
|
|
321
|
-
candidate: candidateWorld,
|
|
322
321
|
context: taskContext,
|
|
323
322
|
establish: coordinates.establishWorld,
|
|
324
323
|
readStdin: edge.readStdin,
|
|
@@ -59,9 +59,6 @@ function projectRefusal(refusal) {
|
|
|
59
59
|
if (refusal.kind === "relation-owned-by-other") {
|
|
60
60
|
return { line: `relation-owned-by-other ${refusal.taskId} ${refusal.related} ${refusal.declaringTask}` };
|
|
61
61
|
}
|
|
62
|
-
if (refusal.kind === "invalid-composition") {
|
|
63
|
-
return { line: refusal.kind, compositionDiagnostics: refusal.diagnostics };
|
|
64
|
-
}
|
|
65
62
|
return { line: refusal.kind, diagnostic: refusal.diagnostic };
|
|
66
63
|
}
|
|
67
64
|
function appendDiagnostic(lines, diagnostic) {
|
|
@@ -76,9 +73,6 @@ function renderFailure(verb, result, columns) {
|
|
|
76
73
|
const facts = projectRefusal(result.refusal);
|
|
77
74
|
lines.push(facts.line);
|
|
78
75
|
appendDiagnostic(lines, facts.diagnostic);
|
|
79
|
-
for (const item of facts.compositionDiagnostics ?? []) {
|
|
80
|
-
lines.push(`line ${item.line} · ${safeText(item.reason)} · ${safeText(item.token)}`);
|
|
81
|
-
}
|
|
82
76
|
return lines.join("\n");
|
|
83
77
|
}
|
|
84
78
|
function edge(label, ref, mark) {
|
|
@@ -262,30 +256,12 @@ function stoppedLines(stopped) {
|
|
|
262
256
|
appendDiagnostic(lines, facts.diagnostic);
|
|
263
257
|
return lines;
|
|
264
258
|
}
|
|
265
|
-
function aliasLines(aliases) {
|
|
266
|
-
return aliases.map((binding) => `alias ^${binding.alias} ${binding.taskId}`);
|
|
267
|
-
}
|
|
268
|
-
function renderPlan(result) {
|
|
269
|
-
const lines = [
|
|
270
|
-
`compose plan · ${result.admissionOrder.length} documents`,
|
|
271
|
-
...aliasLines(result.aliases),
|
|
272
|
-
...result.admissionOrder.map((id, index) => `admit ${index + 1} ${id}`),
|
|
273
|
-
];
|
|
274
|
-
for (const body of result.bodies) {
|
|
275
|
-
lines.push(`body ${body.taskId} · ${body.bytes} bytes`);
|
|
276
|
-
lines.push(` first ${safeText(body.firstLine)}`);
|
|
277
|
-
lines.push(` last ${safeText(body.lastLine)}`);
|
|
278
|
-
}
|
|
279
|
-
return lines.join("\n");
|
|
280
|
-
}
|
|
281
259
|
function renderCompose(result, columns) {
|
|
282
260
|
if (result.kind === "incomplete")
|
|
283
261
|
return "";
|
|
284
|
-
if (result.kind === "planned")
|
|
285
|
-
return renderPlan(result);
|
|
286
262
|
if (result.kind !== "accepted")
|
|
287
263
|
return renderFailure("compose", result, columns);
|
|
288
|
-
const lines = [`✓ compose accepted · ${result.documentChanges.length} changed
|
|
264
|
+
const lines = [`✓ compose accepted · ${result.documentChanges.length} changed`];
|
|
289
265
|
composeDiffs(lines, result.documentChanges);
|
|
290
266
|
return lines.join("\n");
|
|
291
267
|
}
|
|
Binary file
|
|
@@ -1,30 +1,18 @@
|
|
|
1
|
+
import { type TaskId } from "./identity.js";
|
|
1
2
|
import type { WorldRoot } from "../world.js";
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
import type { TaskRefusal, TaskRetry } from "./operations.js";
|
|
5
|
-
export type TaskDocumentChange = Readonly<{
|
|
3
|
+
import { type TaskRefusal, type TaskRetry } from "./operations.js";
|
|
4
|
+
type TaskDocumentChange = Readonly<{
|
|
6
5
|
taskId: TaskId;
|
|
7
6
|
kind: "created" | "updated";
|
|
8
7
|
documentDiff: string;
|
|
9
8
|
}>;
|
|
10
|
-
export type TaskCompositionFacts = Readonly<{
|
|
11
|
-
aliases: readonly TaskCompositionAlias[];
|
|
12
|
-
admissionOrder: readonly TaskId[];
|
|
13
|
-
}>;
|
|
14
9
|
export type TaskCompositionResult = Readonly<{
|
|
15
|
-
kind: "planned";
|
|
16
|
-
aliases: readonly TaskCompositionAlias[];
|
|
17
|
-
admissionOrder: readonly TaskId[];
|
|
18
|
-
bodies: readonly TaskCompositionBodyPreview[];
|
|
19
|
-
}> | Readonly<{
|
|
20
10
|
kind: "accepted";
|
|
21
11
|
documentChanges: readonly TaskDocumentChange[];
|
|
22
|
-
}
|
|
12
|
+
}> | Readonly<{
|
|
23
13
|
kind: "refused";
|
|
24
|
-
refusal:
|
|
25
|
-
|
|
26
|
-
}>;
|
|
27
|
-
}> | (Readonly<{
|
|
14
|
+
refusal: TaskRefusal;
|
|
15
|
+
}> | Readonly<{
|
|
28
16
|
kind: "incomplete";
|
|
29
17
|
documentChanges: readonly TaskDocumentChange[];
|
|
30
18
|
stopped: TaskRefusal | Readonly<{
|
|
@@ -32,5 +20,6 @@ export type TaskCompositionResult = Readonly<{
|
|
|
32
20
|
reason: TaskRetry;
|
|
33
21
|
}>;
|
|
34
22
|
draft: string;
|
|
35
|
-
}
|
|
36
|
-
export declare function composeTasks(world: WorldRoot, markdown: string, signal?: AbortSignal, actor?: string, defaultNamespace?: readonly string[]
|
|
23
|
+
}>;
|
|
24
|
+
export declare function composeTasks(world: WorldRoot, markdown: string, signal?: AbortSignal, actor?: string, defaultNamespace?: readonly string[]): Promise<TaskCompositionResult>;
|
|
25
|
+
export {};
|
|
@@ -1,142 +1,305 @@
|
|
|
1
1
|
import { documentDiff } from "../markdown/diff.js";
|
|
2
|
-
import {
|
|
2
|
+
import { relationProblem } from "./board.js";
|
|
3
3
|
import { serializeTaskDocument } from "./document.js";
|
|
4
|
+
import { allocateLocalId, deriveLocalStem, formatTaskId, parseTaskId, sameNamespace } from "./identity.js";
|
|
4
5
|
import { authorityPath, readBoard, replaceAuthority, withTaskLocks } from "./store.js";
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
import { advanceTaskTimestamp } from "./operations.js";
|
|
7
|
+
function failure(diagnostic) {
|
|
8
|
+
return { kind: "invalid-composition", diagnostic };
|
|
7
9
|
}
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
+
function parseNamespace(value) {
|
|
11
|
+
if (value === "")
|
|
12
|
+
return [];
|
|
13
|
+
const coordinate = parseTaskId(`task/${value}/placeholder`);
|
|
14
|
+
return coordinate.namespace;
|
|
10
15
|
}
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const token = suffix === 0 ? "END_BODY" : `END_BODY_${suffix}`;
|
|
22
|
-
if (token.length > 32)
|
|
23
|
-
break;
|
|
24
|
-
if (!lines.has(token))
|
|
25
|
-
return token;
|
|
26
|
-
}
|
|
27
|
-
throw new Error("compose recovery body token space exhausted");
|
|
28
|
-
}
|
|
29
|
-
function taskDraft(task, remainingAliases) {
|
|
30
|
-
const document = task.after;
|
|
31
|
-
const lines = [task.kind === "new" ? `+ ${document.title}` : `@${document.id}`];
|
|
32
|
-
if (task.kind === "new" && task.alias !== undefined)
|
|
33
|
-
lines.push(`as = ${task.alias}`);
|
|
34
|
-
lines.push(`pri = ${document.priority}`, `needs = ${document.needs.map((id) => reference(id, remainingAliases)).join(", ")}`, `parent = ${document.parent === null ? "" : reference(document.parent, remainingAliases)}`, `supersedes = ${document.supersedes.map((id) => reference(id, remainingAliases)).join(", ")}`, `relates = ${document.relates.map((id) => reference(id, remainingAliases)).join(", ")}`);
|
|
35
|
-
if (document.body === "")
|
|
36
|
-
lines.push("body =");
|
|
37
|
-
else {
|
|
38
|
-
const token = bodyToken(document.body);
|
|
39
|
-
lines.push(`body <<${token}`, document.body, token);
|
|
40
|
-
}
|
|
41
|
-
return lines;
|
|
16
|
+
function assignment(token) {
|
|
17
|
+
const matched = /^(parent|needs|supersedes|relates|pri|body)(\+=|=)(.*)$/u.exec(token);
|
|
18
|
+
if (matched === null)
|
|
19
|
+
return null;
|
|
20
|
+
const field = matched[1], append = matched[2] === "+=", value = matched[3];
|
|
21
|
+
if (append && (field === "parent" || field === "pri" || field === "body"))
|
|
22
|
+
throw new TypeError(`${field} does not accept +=`);
|
|
23
|
+
if (field === "body" && (append || value !== ""))
|
|
24
|
+
throw new TypeError("body accepts only bare body=");
|
|
25
|
+
return { field, append, value };
|
|
42
26
|
}
|
|
43
|
-
function
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
27
|
+
function parseNode(text, depth, index) {
|
|
28
|
+
const tokens = text.split(/ +/u);
|
|
29
|
+
if (text.startsWith("+ ")) {
|
|
30
|
+
const values = tokens.slice(1);
|
|
31
|
+
const at = values.findIndex((token) => assignment(token) !== null);
|
|
32
|
+
const title = values.slice(0, at < 0 ? values.length : at).join(" ");
|
|
33
|
+
if (title.trim().length === 0)
|
|
34
|
+
throw new TypeError("compose + node requires a title");
|
|
35
|
+
return {
|
|
36
|
+
index,
|
|
37
|
+
depth,
|
|
38
|
+
kind: "new",
|
|
39
|
+
title,
|
|
40
|
+
assignments: (at < 0 ? [] : values.slice(at)).map((token) => assignment(token) ??
|
|
41
|
+
(() => {
|
|
42
|
+
throw new TypeError(`invalid assignment token: ${token}`);
|
|
43
|
+
})()),
|
|
44
|
+
};
|
|
48
45
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
const [rawId, ...rest] = tokens;
|
|
47
|
+
if (rawId === undefined || !rawId.startsWith("@task/"))
|
|
48
|
+
throw new TypeError("compose node must begin with + or @task/");
|
|
49
|
+
const id = `task/${rawId.slice(1).slice(5)}`;
|
|
50
|
+
parseTaskId(id);
|
|
51
|
+
return {
|
|
52
|
+
index,
|
|
53
|
+
depth,
|
|
54
|
+
kind: "existing",
|
|
55
|
+
id,
|
|
56
|
+
assignments: rest.map((token) => assignment(token) ??
|
|
57
|
+
(() => {
|
|
58
|
+
throw new TypeError(`invalid assignment token: ${token}`);
|
|
59
|
+
})()),
|
|
60
|
+
};
|
|
53
61
|
}
|
|
54
|
-
function
|
|
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
|
-
|
|
62
|
+
function parseSketch(markdown) {
|
|
63
|
+
try {
|
|
64
|
+
const lines = markdown.replace(/\r\n?/gu, "\n").split("\n");
|
|
65
|
+
let namespace;
|
|
66
|
+
let start = 0;
|
|
67
|
+
if (lines[0]?.startsWith("ns=")) {
|
|
68
|
+
namespace = parseNamespace(lines[0].slice(3));
|
|
69
|
+
start = 1;
|
|
70
|
+
}
|
|
71
|
+
const mutable = [];
|
|
72
|
+
for (let line = start; line < lines.length; line += 1) {
|
|
73
|
+
const raw = lines[line];
|
|
74
|
+
if (line === lines.length - 1 && raw === "")
|
|
75
|
+
continue;
|
|
76
|
+
const leading = /^(?:\t| )*/u.exec(raw)[0].replace(/\t/gu, " ");
|
|
77
|
+
const text = raw.slice(/^(?:\t| )*/u.exec(raw)[0].length);
|
|
78
|
+
const nodeLine = text.startsWith("+ ") || text.startsWith("@task/");
|
|
79
|
+
if (nodeLine) {
|
|
80
|
+
if (leading.length % 2 !== 0)
|
|
81
|
+
throw new TypeError(`compose line ${line + 1} has invalid indentation`);
|
|
82
|
+
const depth = leading.length / 2;
|
|
83
|
+
if (depth > 0 && !mutable.some((entry) => entry.node.depth === depth - 1))
|
|
84
|
+
throw new TypeError(`compose line ${line + 1} skips a parent depth`);
|
|
85
|
+
mutable.push({ node: parseNode(text, depth, mutable.length), body: [] });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const current = mutable.at(-1);
|
|
89
|
+
if (current === undefined) {
|
|
90
|
+
if (text.trim() === "")
|
|
91
|
+
continue;
|
|
92
|
+
throw new TypeError(`compose line ${line + 1} has body before a node`);
|
|
93
|
+
}
|
|
94
|
+
current.body.push(text.startsWith("\\") ? text.slice(1) : text);
|
|
82
95
|
}
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
});
|
|
96
|
+
const nodes = mutable.map(({ node, body }) => ({
|
|
97
|
+
...node,
|
|
98
|
+
...(body.length === 0 ? {} : { body: body.join("\n") }),
|
|
99
|
+
}));
|
|
100
|
+
return { ...(namespace === undefined ? {} : { namespace }), nodes };
|
|
89
101
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const fresh = await readBoard(input.world);
|
|
104
|
-
const replanned = planAgainst(input.markdown, fresh.board, input.namespace, input.at, input.actor);
|
|
105
|
-
if (replanned.kind === "refused")
|
|
106
|
-
return refusal(replanned.diagnostics);
|
|
107
|
-
return await admitPlan(input.world, fresh, replanned.plan, input.signal);
|
|
102
|
+
catch (error) {
|
|
103
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function ids(value) {
|
|
107
|
+
if (value === "")
|
|
108
|
+
return [];
|
|
109
|
+
const parsed = value.split(",").map((raw) => {
|
|
110
|
+
if (!raw.startsWith("@task/"))
|
|
111
|
+
throw new TypeError("relation values must be @TaskId");
|
|
112
|
+
const id = raw.slice(1);
|
|
113
|
+
parseTaskId(id);
|
|
114
|
+
return id;
|
|
108
115
|
});
|
|
116
|
+
if (new Set(parsed).size !== parsed.length)
|
|
117
|
+
throw new TypeError("relation values must not contain duplicates");
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
function scalarId(value) {
|
|
121
|
+
const values = ids(value);
|
|
122
|
+
if (values.length > 1)
|
|
123
|
+
throw new TypeError("parent accepts at most one TaskId");
|
|
124
|
+
return values[0] ?? null;
|
|
109
125
|
}
|
|
110
|
-
function
|
|
126
|
+
function changed(current, value, append) {
|
|
127
|
+
return append ? [...current, ...value.filter((id) => !current.includes(id))] : value;
|
|
128
|
+
}
|
|
129
|
+
function applyAssignments(document, assignments, body) {
|
|
130
|
+
let next = document;
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
for (const item of assignments) {
|
|
133
|
+
const mode = `${item.field}:${item.append}`;
|
|
134
|
+
if (seen.has(mode) || [...seen].some((value) => value.startsWith(`${item.field}:`) && value !== mode))
|
|
135
|
+
throw new TypeError(`duplicate compose assignment: ${item.field}`);
|
|
136
|
+
seen.add(mode);
|
|
137
|
+
if (item.field === "pri") {
|
|
138
|
+
const priority = Number(item.value);
|
|
139
|
+
if (!Number.isInteger(priority) || priority < 0 || priority > 3)
|
|
140
|
+
throw new TypeError("pri must be 0..3");
|
|
141
|
+
next = { ...next, priority: priority };
|
|
142
|
+
}
|
|
143
|
+
else if (item.field === "parent")
|
|
144
|
+
next = { ...next, parent: scalarId(item.value) };
|
|
145
|
+
else if (item.field === "body")
|
|
146
|
+
next = { ...next, body: "" };
|
|
147
|
+
else
|
|
148
|
+
next = { ...next, [item.field]: changed(next[item.field], ids(item.value), item.append) };
|
|
149
|
+
}
|
|
150
|
+
return body === undefined ? next : { ...next, body };
|
|
151
|
+
}
|
|
152
|
+
function currentTimestamp() {
|
|
153
|
+
return new Date().toISOString();
|
|
154
|
+
}
|
|
155
|
+
function createdTask(id, title, at, actor) {
|
|
111
156
|
return {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
157
|
+
id,
|
|
158
|
+
title,
|
|
159
|
+
state: "open",
|
|
160
|
+
priority: 2,
|
|
161
|
+
needs: [],
|
|
162
|
+
parent: null,
|
|
163
|
+
supersedes: [],
|
|
164
|
+
relates: [],
|
|
165
|
+
note: "",
|
|
166
|
+
...(actor === undefined ? {} : { createdBy: actor }),
|
|
167
|
+
createdAt: at,
|
|
168
|
+
updatedAt: at,
|
|
169
|
+
body: "",
|
|
118
170
|
};
|
|
119
171
|
}
|
|
120
|
-
|
|
172
|
+
function plan(sketch, board, defaultNamespace, at, actor) {
|
|
173
|
+
try {
|
|
174
|
+
const namespace = sketch.namespace ?? defaultNamespace;
|
|
175
|
+
const occupied = new Set([...board.tasks.values()].flatMap((task) => {
|
|
176
|
+
const coordinate = parseTaskId(task.id);
|
|
177
|
+
return sameNamespace(coordinate.namespace, namespace) ? [coordinate.localId] : [];
|
|
178
|
+
}));
|
|
179
|
+
const allocations = new Map();
|
|
180
|
+
for (const node of sketch.nodes)
|
|
181
|
+
if (node.kind === "new") {
|
|
182
|
+
const localId = allocateLocalId(deriveLocalStem(node.title), occupied);
|
|
183
|
+
occupied.add(localId);
|
|
184
|
+
const coordinate = { namespace, localId };
|
|
185
|
+
allocations.set(node.index, createdTask(formatTaskId(coordinate), node.title, at, actor));
|
|
186
|
+
}
|
|
187
|
+
const all = new Map(board.tasks);
|
|
188
|
+
for (const allocated of allocations.values())
|
|
189
|
+
all.set(allocated.id, allocated);
|
|
190
|
+
const byDepth = [], addressed = new Set();
|
|
191
|
+
const planned = [];
|
|
192
|
+
for (const node of sketch.nodes) {
|
|
193
|
+
const before = node.kind === "new" ? null : (board.tasks.get(node.id) ?? null);
|
|
194
|
+
let current = node.kind === "new" ? allocations.get(node.index) : all.get(node.id);
|
|
195
|
+
if (current === undefined)
|
|
196
|
+
throw new TypeError(`compose task does not exist: ${node.id}`);
|
|
197
|
+
if (addressed.has(current.id))
|
|
198
|
+
throw new TypeError(`compose addresses ${current.id} more than once`);
|
|
199
|
+
addressed.add(current.id);
|
|
200
|
+
if (node.depth > 0) {
|
|
201
|
+
const parent = byDepth[node.depth - 1];
|
|
202
|
+
if (parent === undefined)
|
|
203
|
+
throw new TypeError("compose parent is unavailable");
|
|
204
|
+
if (node.assignments.some((item) => item.field === "parent"))
|
|
205
|
+
throw new TypeError("indented node cannot also assign parent");
|
|
206
|
+
current = { ...current, parent };
|
|
207
|
+
}
|
|
208
|
+
current = applyAssignments(current, node.assignments, node.body);
|
|
209
|
+
if (before !== null &&
|
|
210
|
+
!Buffer.from(serializeTaskDocument(current)).equals(Buffer.from(serializeTaskDocument(before)))) {
|
|
211
|
+
current = {
|
|
212
|
+
...current,
|
|
213
|
+
updatedAt: advanceTaskTimestamp(before.updatedAt, at),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
byDepth[node.depth] = current.id;
|
|
217
|
+
byDepth.length = node.depth + 1;
|
|
218
|
+
all.set(current.id, current);
|
|
219
|
+
planned.push({ node, before, after: current });
|
|
220
|
+
}
|
|
221
|
+
for (const item of planned) {
|
|
222
|
+
const problem = relationProblem({ tasks: all }, item.before, item.after);
|
|
223
|
+
if (problem !== null)
|
|
224
|
+
return failure(problem);
|
|
225
|
+
}
|
|
226
|
+
return planned;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function ref(id) {
|
|
233
|
+
return `@${id}`;
|
|
234
|
+
}
|
|
235
|
+
function bodyLines(body) {
|
|
236
|
+
return body.split("\n").map((line) => (/^[\\]|^\+ |^@task\//u.test(line) ? `\\${line}` : line));
|
|
237
|
+
}
|
|
238
|
+
function draft(namespace, remaining) {
|
|
239
|
+
const lines = [`ns=${namespace.join("/")}`];
|
|
240
|
+
for (const item of remaining) {
|
|
241
|
+
const task = item.after;
|
|
242
|
+
const assignments = [
|
|
243
|
+
`pri=${task.priority}`,
|
|
244
|
+
`needs=${task.needs.map(ref).join(",")}`,
|
|
245
|
+
`parent=${task.parent === null ? "" : ref(task.parent)}`,
|
|
246
|
+
`supersedes=${task.supersedes.map(ref).join(",")}`,
|
|
247
|
+
`relates=${task.relates.map(ref).join(",")}`,
|
|
248
|
+
];
|
|
249
|
+
lines.push(`${item.node.kind === "new" ? `+ ${task.title}` : ref(task.id)} ${assignments.join(" ")}${task.body === "" ? " body=" : ""}`);
|
|
250
|
+
if (task.body !== "")
|
|
251
|
+
lines.push(...bodyLines(task.body));
|
|
252
|
+
}
|
|
253
|
+
return `${lines.join("\n")}\n`;
|
|
254
|
+
}
|
|
255
|
+
export async function composeTasks(world, markdown, signal, actor, defaultNamespace) {
|
|
256
|
+
const sketch = parseSketch(markdown);
|
|
257
|
+
if ("kind" in sketch)
|
|
258
|
+
return { kind: "refused", refusal: sketch };
|
|
121
259
|
const at = currentTimestamp();
|
|
260
|
+
const namespace = sketch.namespace ?? defaultNamespace ?? [];
|
|
122
261
|
const initial = await readBoard(world);
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
125
|
-
return refusal
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
262
|
+
const planned = plan(sketch, initial.board, namespace, at, actor);
|
|
263
|
+
if ("kind" in planned)
|
|
264
|
+
return { kind: "refused", refusal: planned };
|
|
265
|
+
const ordered = [...planned].sort((a, b) => Buffer.compare(Buffer.from(a.after.id), Buffer.from(b.after.id)));
|
|
266
|
+
const allocation = sketch.nodes.some((node) => node.kind === "new");
|
|
267
|
+
const result = await withTaskLocks({ world, allocation, ids: ordered.map((item) => item.after.id), ...(signal === undefined ? {} : { signal }) }, async () => {
|
|
268
|
+
const fresh = await readBoard(world);
|
|
269
|
+
const replanned = plan(sketch, fresh.board, namespace, at, actor);
|
|
270
|
+
if ("kind" in replanned)
|
|
271
|
+
return { kind: "refused", refusal: replanned };
|
|
272
|
+
const queue = [...replanned].sort((a, b) => Buffer.compare(Buffer.from(a.after.id), Buffer.from(b.after.id))), changes = [];
|
|
273
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
274
|
+
signal?.throwIfAborted();
|
|
275
|
+
const item = queue[index], path = authorityPath(world, item.after.id);
|
|
276
|
+
const beforeBytes = item.before === null ? null : (fresh.bytes.get(item.after.id) ?? null);
|
|
277
|
+
const afterBytes = serializeTaskDocument(item.after);
|
|
278
|
+
const before = beforeBytes === null ? "" : Buffer.from(beforeBytes).toString("utf8"), after = Buffer.from(afterBytes).toString("utf8");
|
|
279
|
+
if (before === after)
|
|
280
|
+
continue;
|
|
281
|
+
if ((await replaceAuthority({ path, expected: beforeBytes, next: afterBytes })) !== "replaced")
|
|
282
|
+
return {
|
|
283
|
+
kind: "incomplete",
|
|
284
|
+
documentChanges: changes,
|
|
285
|
+
stopped: { kind: "retry", reason: "concurrent-modification" },
|
|
286
|
+
draft: draft(namespace, queue.slice(index)),
|
|
287
|
+
};
|
|
288
|
+
const label = `${item.after.id}.md`;
|
|
289
|
+
changes.push({
|
|
290
|
+
taskId: item.after.id,
|
|
291
|
+
kind: item.before === null ? "created" : "updated",
|
|
292
|
+
documentDiff: documentDiff(label, label, before, after),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return { kind: "accepted", documentChanges: changes };
|
|
296
|
+
});
|
|
297
|
+
return result === "busy"
|
|
298
|
+
? {
|
|
299
|
+
kind: "incomplete",
|
|
300
|
+
documentChanges: [],
|
|
301
|
+
stopped: { kind: "retry", reason: "busy" },
|
|
302
|
+
draft: draft(namespace, ordered),
|
|
303
|
+
}
|
|
304
|
+
: result;
|
|
142
305
|
}
|
|
@@ -4,7 +4,7 @@ import { type BlockedTaskRow, type TaskDetailFacts, type TaskDoctorIssue, type T
|
|
|
4
4
|
import { type TaskCompositionResult } from "./compose.js";
|
|
5
5
|
import { TaskAuthorityCorruptionError, type TaskPriority, type TaskState } from "./document.js";
|
|
6
6
|
import type { TaskId } from "./identity.js";
|
|
7
|
-
import { type AddTaskDocumentInput, type AddTaskInput, type TaskBatchResult, type
|
|
7
|
+
import { type AddTaskDocumentInput, type AddTaskInput, type TaskBatchResult, type TaskMutationResult, type TaskOutcome, type TaskRefusal, type TaskRetry, type TaskUpdateResult, type TaskView, type UpdateTaskInput } from "./operations.js";
|
|
8
8
|
import { TASK_RELATION_PREDICATE_FIELDS, type TaskPage, type TaskQueryExpression, type TaskQueryPredicate, type TaskQueryRow, type TaskQuerySort, type TaskRelationPredicateField } from "./query.js";
|
|
9
9
|
export type TaskDetail = Omit<TaskDetailFacts, "task"> & Readonly<{
|
|
10
10
|
task: TaskView;
|
|
@@ -17,7 +17,7 @@ export type TaskDoctorReport = Readonly<{
|
|
|
17
17
|
issues: readonly TaskDoctorIssue[];
|
|
18
18
|
}>;
|
|
19
19
|
export type TaskDecompositionTree = TaskOutcome<TaskTreeNode>;
|
|
20
|
-
export type { AddTaskDocumentInput, AddTaskInput, BlockedTaskRow, TaskBatchResult,
|
|
20
|
+
export type { AddTaskDocumentInput, AddTaskInput, BlockedTaskRow, TaskBatchResult, TaskCompositionResult, TaskDoctorIssue, TaskId, TaskMutationResult, TaskOutcome, TaskPriority, TaskRef, TaskRefusal, TaskRetry, TaskRow, TaskState, TaskTreeNode, TaskUpdateResult, TaskView, UpdateTaskInput, TaskPage, TaskQueryExpression, TaskQueryPredicate, TaskQueryRow, TaskQuerySort, TaskRelationPredicateField, };
|
|
21
21
|
export { TaskAuthorityCorruptionError, TASK_RELATION_PREDICATE_FIELDS };
|
|
22
22
|
declare class TaskHandle {
|
|
23
23
|
readonly id: TaskId;
|
|
@@ -95,7 +95,6 @@ declare class TasksHandle {
|
|
|
95
95
|
namespace?: readonly string[];
|
|
96
96
|
actor?: string;
|
|
97
97
|
signal?: AbortSignal;
|
|
98
|
-
plan?: boolean;
|
|
99
98
|
}>): Promise<TaskCompositionResult>;
|
|
100
99
|
}
|
|
101
100
|
export type Tasks = TasksHandle;
|
package/build/src/task/index.js
CHANGED
|
@@ -149,14 +149,12 @@ class TasksHandle {
|
|
|
149
149
|
}
|
|
150
150
|
compose(input) {
|
|
151
151
|
const v = record(input, "compose input");
|
|
152
|
-
closed(v, ["markdown", "namespace", "actor", "signal"
|
|
152
|
+
closed(v, ["markdown", "namespace", "actor", "signal"], "compose input");
|
|
153
153
|
const markdown = text(v.markdown, "markdown");
|
|
154
154
|
if (markdown === undefined)
|
|
155
155
|
throw new TypeError("markdown is required");
|
|
156
|
-
if (v.plan !== undefined && typeof v.plan !== "boolean")
|
|
157
|
-
throw new TypeError("plan must be a boolean");
|
|
158
156
|
const selected = namespace(v.namespace);
|
|
159
|
-
return composeTasks(this.world, markdown, signal(v.signal), actor(v.actor), selected
|
|
157
|
+
return composeTasks(this.world, markdown, signal(v.signal), actor(v.actor), selected);
|
|
160
158
|
}
|
|
161
159
|
}
|
|
162
160
|
export const Tasks = Object.freeze({
|
|
@@ -9,11 +9,6 @@ export type TaskView = Readonly<TaskDocument & {
|
|
|
9
9
|
export declare function nukeTask(world: WorldRoot, options?: Readonly<{
|
|
10
10
|
timeoutMs?: number;
|
|
11
11
|
}>): Promise<void>;
|
|
12
|
-
export type TaskCompositionDiagnostic = Readonly<{
|
|
13
|
-
line: number;
|
|
14
|
-
reason: string;
|
|
15
|
-
token: string;
|
|
16
|
-
}>;
|
|
17
12
|
export type TaskRefusal = Readonly<{
|
|
18
13
|
kind: "task-missing";
|
|
19
14
|
taskId: TaskId;
|
|
@@ -35,7 +30,7 @@ export type TaskRefusal = Readonly<{
|
|
|
35
30
|
declaringTask: TaskId;
|
|
36
31
|
}> | Readonly<{
|
|
37
32
|
kind: "invalid-composition";
|
|
38
|
-
|
|
33
|
+
diagnostic: string;
|
|
39
34
|
}>;
|
|
40
35
|
export type TaskRetry = "busy" | "concurrent-modification";
|
|
41
36
|
export type TaskOutcome<A> = Readonly<{
|
package/package.json
CHANGED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import type { TaskBoard } from "./board.js";
|
|
2
|
-
import { type TaskDocument } from "./document.js";
|
|
3
|
-
import { type TaskId } from "./identity.js";
|
|
4
|
-
import { type TaskCompositionDiagnostic } from "./operations.js";
|
|
5
|
-
export type TaskCompositionAlias = Readonly<{
|
|
6
|
-
alias: string;
|
|
7
|
-
taskId: TaskId;
|
|
8
|
-
}>;
|
|
9
|
-
export type TaskCompositionBodyPreview = Readonly<{
|
|
10
|
-
taskId: TaskId;
|
|
11
|
-
bytes: number;
|
|
12
|
-
firstLine: string;
|
|
13
|
-
lastLine: string;
|
|
14
|
-
}>;
|
|
15
|
-
export type PlannedTask = Readonly<{
|
|
16
|
-
index: number;
|
|
17
|
-
line: number;
|
|
18
|
-
kind: "new" | "existing";
|
|
19
|
-
alias?: string;
|
|
20
|
-
before: TaskDocument | null;
|
|
21
|
-
after: TaskDocument;
|
|
22
|
-
}>;
|
|
23
|
-
export type TaskCompositionPlan = Readonly<{
|
|
24
|
-
namespace: readonly string[];
|
|
25
|
-
aliases: readonly TaskCompositionAlias[];
|
|
26
|
-
admissionOrder: readonly TaskId[];
|
|
27
|
-
bodies: readonly TaskCompositionBodyPreview[];
|
|
28
|
-
tasks: readonly PlannedTask[];
|
|
29
|
-
}>;
|
|
30
|
-
export type TaskCompositionPlanning = Readonly<{
|
|
31
|
-
kind: "planned";
|
|
32
|
-
plan: TaskCompositionPlan;
|
|
33
|
-
}> | Readonly<{
|
|
34
|
-
kind: "refused";
|
|
35
|
-
diagnostics: readonly TaskCompositionDiagnostic[];
|
|
36
|
-
}>;
|
|
37
|
-
export declare function planTaskComposition(input: Readonly<{
|
|
38
|
-
markdown: string;
|
|
39
|
-
board: TaskBoard;
|
|
40
|
-
namespace: readonly string[];
|
|
41
|
-
at: string;
|
|
42
|
-
actor?: string;
|
|
43
|
-
}>): TaskCompositionPlanning;
|
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
import { serializeTaskDocument } from "./document.js";
|
|
2
|
-
import { allocateLocalId, deriveLocalStem, formatTaskId, parseTaskId, sameNamespace } from "./identity.js";
|
|
3
|
-
import { parseTaskComposition } from "./compose-parser.js";
|
|
4
|
-
import { advanceTaskTimestamp } from "./operations.js";
|
|
5
|
-
function diagnostic(line, reason, token) {
|
|
6
|
-
return { line, reason, token };
|
|
7
|
-
}
|
|
8
|
-
function occupied(board, namespace) {
|
|
9
|
-
return new Set([...board.tasks.values()].flatMap((task) => {
|
|
10
|
-
const coordinate = parseTaskId(task.id);
|
|
11
|
-
return sameNamespace(coordinate.namespace, namespace) ? [coordinate.localId] : [];
|
|
12
|
-
}));
|
|
13
|
-
}
|
|
14
|
-
function createdTask(id, title, at, actor) {
|
|
15
|
-
return {
|
|
16
|
-
id,
|
|
17
|
-
title,
|
|
18
|
-
state: "open",
|
|
19
|
-
priority: 2,
|
|
20
|
-
needs: [],
|
|
21
|
-
parent: null,
|
|
22
|
-
supersedes: [],
|
|
23
|
-
relates: [],
|
|
24
|
-
note: "",
|
|
25
|
-
...(actor === undefined ? {} : { createdBy: actor }),
|
|
26
|
-
createdAt: at,
|
|
27
|
-
updatedAt: at,
|
|
28
|
-
body: "",
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
function aliasFor(node) {
|
|
32
|
-
return node.assignments.find((item) => item.field === "as");
|
|
33
|
-
}
|
|
34
|
-
function allocateNodes(parsed, board, namespace, at, actor, diagnostics) {
|
|
35
|
-
const ids = occupied(board, namespace);
|
|
36
|
-
const allocations = new Map();
|
|
37
|
-
for (const node of parsed.nodes) {
|
|
38
|
-
if (node.kind !== "new")
|
|
39
|
-
continue;
|
|
40
|
-
try {
|
|
41
|
-
const localId = allocateLocalId(deriveLocalStem(node.title), ids);
|
|
42
|
-
ids.add(localId);
|
|
43
|
-
const id = formatTaskId({ namespace, localId });
|
|
44
|
-
allocations.set(node.index, createdTask(id, node.title, at, actor));
|
|
45
|
-
}
|
|
46
|
-
catch (error) {
|
|
47
|
-
diagnostics.push(diagnostic(node.line, error instanceof Error ? error.message : String(error), node.title));
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return allocations;
|
|
51
|
-
}
|
|
52
|
-
function collectAliases(nodes, allocations, diagnostics) {
|
|
53
|
-
const aliases = new Map();
|
|
54
|
-
for (const node of nodes) {
|
|
55
|
-
const declarations = node.assignments.filter((item) => item.field === "as");
|
|
56
|
-
for (const duplicate of declarations.slice(1)) {
|
|
57
|
-
diagnostics.push(diagnostic(duplicate.line, "alias is assigned more than once", duplicate.token));
|
|
58
|
-
}
|
|
59
|
-
const declaration = declarations[0];
|
|
60
|
-
if (declaration === undefined)
|
|
61
|
-
continue;
|
|
62
|
-
if (node.kind !== "new") {
|
|
63
|
-
diagnostics.push(diagnostic(declaration.line, "only a new task may declare an alias", declaration.token));
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (declaration.operator !== "=" || !/^[a-z0-9-]+$/u.test(declaration.value)) {
|
|
67
|
-
diagnostics.push(diagnostic(declaration.line, "alias must use as = [a-z0-9-]+", declaration.token));
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (aliases.has(declaration.value)) {
|
|
71
|
-
diagnostics.push(diagnostic(declaration.line, "alias must be unique in the composition", declaration.value));
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
const allocated = allocations.get(node.index);
|
|
75
|
-
if (allocated !== undefined)
|
|
76
|
-
aliases.set(declaration.value, allocated.id);
|
|
77
|
-
}
|
|
78
|
-
return aliases;
|
|
79
|
-
}
|
|
80
|
-
function resolveReference(raw, line, board, aliases, diagnostics) {
|
|
81
|
-
if (raw.startsWith("^")) {
|
|
82
|
-
const alias = raw.slice(1);
|
|
83
|
-
const resolved = aliases.get(alias);
|
|
84
|
-
if (resolved === undefined)
|
|
85
|
-
diagnostics.push(diagnostic(line, "new-task alias is not declared", raw));
|
|
86
|
-
return resolved;
|
|
87
|
-
}
|
|
88
|
-
if (!raw.startsWith("@task/")) {
|
|
89
|
-
diagnostics.push(diagnostic(line, "reference must be @task/... or ^alias", raw));
|
|
90
|
-
return undefined;
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
const id = raw.slice(1);
|
|
94
|
-
parseTaskId(id);
|
|
95
|
-
if (!board.tasks.has(id))
|
|
96
|
-
diagnostics.push(diagnostic(line, "@ reference must name a pre-existing task", raw));
|
|
97
|
-
return board.tasks.has(id) ? id : undefined;
|
|
98
|
-
}
|
|
99
|
-
catch (error) {
|
|
100
|
-
diagnostics.push(diagnostic(line, error instanceof Error ? error.message : String(error), raw));
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
function referenceList(assignment, board, aliases, diagnostics) {
|
|
105
|
-
if (assignment.value === "")
|
|
106
|
-
return [];
|
|
107
|
-
const raw = assignment.value.split(",").map((item) => item.trim());
|
|
108
|
-
if (raw.some((item) => item.length === 0)) {
|
|
109
|
-
diagnostics.push(diagnostic(assignment.line, "reference list contains an empty item", assignment.token));
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
const resolved = raw.map((item) => resolveReference(item, assignment.line, board, aliases, diagnostics));
|
|
113
|
-
if (resolved.some((item) => item === undefined))
|
|
114
|
-
return undefined;
|
|
115
|
-
const ids = resolved;
|
|
116
|
-
if (new Set(ids).size !== ids.length) {
|
|
117
|
-
diagnostics.push(diagnostic(assignment.line, "reference list contains a duplicate", assignment.token));
|
|
118
|
-
return undefined;
|
|
119
|
-
}
|
|
120
|
-
return ids;
|
|
121
|
-
}
|
|
122
|
-
function patchRelation(current, assignment, values, diagnostics) {
|
|
123
|
-
if (assignment.operator === "=")
|
|
124
|
-
return values;
|
|
125
|
-
if (assignment.operator === "+=")
|
|
126
|
-
return [...current, ...values.filter((id) => !current.includes(id))];
|
|
127
|
-
const missing = values.filter((id) => !current.includes(id));
|
|
128
|
-
for (const id of missing)
|
|
129
|
-
diagnostics.push(diagnostic(assignment.line, "cannot remove an absent relation", `@${id}`));
|
|
130
|
-
return missing.length === 0 ? current.filter((id) => !values.includes(id)) : current;
|
|
131
|
-
}
|
|
132
|
-
function applyAssignments(node, base, board, aliases, diagnostics) {
|
|
133
|
-
let next = base;
|
|
134
|
-
const scalar = new Set();
|
|
135
|
-
for (const assignment of node.assignments) {
|
|
136
|
-
if (assignment.field === "as")
|
|
137
|
-
continue;
|
|
138
|
-
if (assignment.field === "pri" || assignment.field === "parent") {
|
|
139
|
-
if (scalar.has(assignment.field)) {
|
|
140
|
-
diagnostics.push(diagnostic(assignment.line, `${assignment.field} is assigned more than once`, assignment.token));
|
|
141
|
-
}
|
|
142
|
-
scalar.add(assignment.field);
|
|
143
|
-
}
|
|
144
|
-
if (assignment.field === "pri") {
|
|
145
|
-
if (assignment.operator !== "=" || !/^[0-3]$/u.test(assignment.value)) {
|
|
146
|
-
diagnostics.push(diagnostic(assignment.line, "pri must use = with a value from 0 through 3", assignment.token));
|
|
147
|
-
}
|
|
148
|
-
else
|
|
149
|
-
next = { ...next, priority: Number(assignment.value) };
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
if (assignment.field === "parent") {
|
|
153
|
-
if (assignment.operator !== "=") {
|
|
154
|
-
diagnostics.push(diagnostic(assignment.line, "parent accepts only =", assignment.token));
|
|
155
|
-
}
|
|
156
|
-
else if (assignment.value === "")
|
|
157
|
-
next = { ...next, parent: null };
|
|
158
|
-
else {
|
|
159
|
-
const id = resolveReference(assignment.value.trim(), assignment.line, board, aliases, diagnostics);
|
|
160
|
-
if (id !== undefined)
|
|
161
|
-
next = { ...next, parent: id };
|
|
162
|
-
}
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
const values = referenceList(assignment, board, aliases, diagnostics);
|
|
166
|
-
if (values === undefined)
|
|
167
|
-
continue;
|
|
168
|
-
if (assignment.operator !== "=" && values.length === 0) {
|
|
169
|
-
diagnostics.push(diagnostic(assignment.line, `${assignment.operator} requires at least one reference`, assignment.token));
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
next = { ...next, [assignment.field]: patchRelation(next[assignment.field], assignment, values, diagnostics) };
|
|
173
|
-
}
|
|
174
|
-
if (node.body !== undefined)
|
|
175
|
-
next = { ...next, body: node.body.value };
|
|
176
|
-
for (const field of ["needs", "supersedes", "relates"]) {
|
|
177
|
-
if (next[field].includes(next.id))
|
|
178
|
-
diagnostics.push(diagnostic(node.line, `${field} cannot reference the task itself`, next.id));
|
|
179
|
-
}
|
|
180
|
-
if (next.parent === next.id)
|
|
181
|
-
diagnostics.push(diagnostic(node.line, "parent cannot reference the task itself", next.id));
|
|
182
|
-
return next;
|
|
183
|
-
}
|
|
184
|
-
function relationTargets(document, relation) {
|
|
185
|
-
return relation === "needs" ? document.needs : document.parent === null ? [] : [document.parent];
|
|
186
|
-
}
|
|
187
|
-
function pathExists(board, start, goal, relation) {
|
|
188
|
-
const seen = new Set();
|
|
189
|
-
const pending = [start];
|
|
190
|
-
while (pending.length > 0) {
|
|
191
|
-
const id = pending.pop();
|
|
192
|
-
if (id === goal)
|
|
193
|
-
return true;
|
|
194
|
-
if (seen.has(id))
|
|
195
|
-
continue;
|
|
196
|
-
seen.add(id);
|
|
197
|
-
const task = board.tasks.get(id);
|
|
198
|
-
if (task !== undefined)
|
|
199
|
-
pending.push(...relationTargets(task, relation));
|
|
200
|
-
}
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
function diagnoseIntroducedCycles(after, tasks, diagnostics) {
|
|
204
|
-
for (const task of tasks) {
|
|
205
|
-
for (const relation of ["needs", "parent"]) {
|
|
206
|
-
const old = task.before === null ? [] : relationTargets(task.before, relation);
|
|
207
|
-
for (const target of relationTargets(task.after, relation)) {
|
|
208
|
-
if (!old.includes(target) && pathExists(after, target, task.after.id, relation)) {
|
|
209
|
-
diagnostics.push(diagnostic(task.line, `${relation} edge creates a cycle`, `${task.after.id} -> ${target}`));
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
function changed(task) {
|
|
216
|
-
return (task.before === null ||
|
|
217
|
-
!Buffer.from(serializeTaskDocument(task.before)).equals(Buffer.from(serializeTaskDocument(task.after))));
|
|
218
|
-
}
|
|
219
|
-
function stableAdmissionOrder(tasks, diagnostics) {
|
|
220
|
-
const candidates = tasks.filter(changed);
|
|
221
|
-
const byId = new Map(candidates.map((task) => [task.after.id, task]));
|
|
222
|
-
const dependencies = new Map(candidates.map((task) => [task.after.id, new Set()]));
|
|
223
|
-
for (const task of candidates) {
|
|
224
|
-
const targets = [...task.after.needs, ...(task.after.parent === null ? [] : [task.after.parent])];
|
|
225
|
-
for (const target of targets) {
|
|
226
|
-
const plannedTarget = byId.get(target);
|
|
227
|
-
if (plannedTarget?.before === null)
|
|
228
|
-
dependencies.get(task.after.id).add(target);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
const remaining = new Set(candidates.map((task) => task.after.id));
|
|
232
|
-
const ordered = [];
|
|
233
|
-
while (remaining.size > 0) {
|
|
234
|
-
const ready = candidates.find((task) => remaining.has(task.after.id) && [...dependencies.get(task.after.id)].every((id) => !remaining.has(id)));
|
|
235
|
-
if (ready === undefined) {
|
|
236
|
-
const first = candidates.find((task) => remaining.has(task.after.id));
|
|
237
|
-
diagnostics.push(diagnostic(first.line, "needs and parent references create an admission cycle", first.after.id));
|
|
238
|
-
return [];
|
|
239
|
-
}
|
|
240
|
-
remaining.delete(ready.after.id);
|
|
241
|
-
ordered.push(ready);
|
|
242
|
-
}
|
|
243
|
-
return ordered;
|
|
244
|
-
}
|
|
245
|
-
function bodyPreview(node, id) {
|
|
246
|
-
if (node.body?.kind !== "replace")
|
|
247
|
-
return null;
|
|
248
|
-
const lines = node.body.value.split(/\r\n|\n|\r/u);
|
|
249
|
-
return {
|
|
250
|
-
taskId: id,
|
|
251
|
-
bytes: Buffer.byteLength(node.body.value),
|
|
252
|
-
firstLine: lines[0] ?? "",
|
|
253
|
-
lastLine: lines.at(-1) ?? "",
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
export function planTaskComposition(input) {
|
|
257
|
-
const parsed = parseTaskComposition(input.markdown);
|
|
258
|
-
const diagnostics = [...parsed.diagnostics];
|
|
259
|
-
const namespace = parsed.namespace ?? input.namespace;
|
|
260
|
-
const allocations = allocateNodes(parsed, input.board, namespace, input.at, input.actor, diagnostics);
|
|
261
|
-
const aliases = collectAliases(parsed.nodes, allocations, diagnostics);
|
|
262
|
-
const addressed = new Set();
|
|
263
|
-
const tasks = [];
|
|
264
|
-
for (const node of parsed.nodes) {
|
|
265
|
-
const before = node.kind === "new" ? null : (input.board.tasks.get(node.id) ?? null);
|
|
266
|
-
const base = node.kind === "new" ? allocations.get(node.index) : before;
|
|
267
|
-
if (base === null || base === undefined) {
|
|
268
|
-
diagnostics.push(diagnostic(node.line, "existing task does not exist before composition", `@${node.id}`));
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (addressed.has(base.id)) {
|
|
272
|
-
diagnostics.push(diagnostic(node.line, "task is addressed more than once", base.id));
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
addressed.add(base.id);
|
|
276
|
-
let after = applyAssignments(node, base, input.board, aliases, diagnostics);
|
|
277
|
-
if (before !== null && changed({ index: node.index, line: node.line, kind: node.kind, before, after })) {
|
|
278
|
-
after = { ...after, updatedAt: advanceTaskTimestamp(before.updatedAt, input.at) };
|
|
279
|
-
}
|
|
280
|
-
tasks.push({
|
|
281
|
-
index: node.index,
|
|
282
|
-
line: node.line,
|
|
283
|
-
kind: node.kind,
|
|
284
|
-
...(aliasFor(node) === undefined ? {} : { alias: aliasFor(node).value }),
|
|
285
|
-
before,
|
|
286
|
-
after,
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
const post = new Map(input.board.tasks);
|
|
290
|
-
for (const task of tasks)
|
|
291
|
-
post.set(task.after.id, task.after);
|
|
292
|
-
diagnoseIntroducedCycles({ tasks: post }, tasks, diagnostics);
|
|
293
|
-
const ordered = stableAdmissionOrder(tasks, diagnostics);
|
|
294
|
-
if (diagnostics.length > 0)
|
|
295
|
-
return { kind: "refused", diagnostics };
|
|
296
|
-
const aliasBindings = [...aliases].map(([alias, taskId]) => ({ alias, taskId }));
|
|
297
|
-
const bodies = parsed.nodes.flatMap((node) => {
|
|
298
|
-
const task = tasks.find((candidate) => candidate.index === node.index);
|
|
299
|
-
if (task === undefined)
|
|
300
|
-
return [];
|
|
301
|
-
const preview = bodyPreview(node, task.after.id);
|
|
302
|
-
return preview === null ? [] : [preview];
|
|
303
|
-
});
|
|
304
|
-
return {
|
|
305
|
-
kind: "planned",
|
|
306
|
-
plan: {
|
|
307
|
-
namespace,
|
|
308
|
-
aliases: aliasBindings,
|
|
309
|
-
admissionOrder: ordered.map((task) => task.after.id),
|
|
310
|
-
bodies,
|
|
311
|
-
tasks: ordered,
|
|
312
|
-
},
|
|
313
|
-
};
|
|
314
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { type TaskId } from "./identity.js";
|
|
2
|
-
import type { TaskCompositionDiagnostic } from "./operations.js";
|
|
3
|
-
export type RelationField = "needs" | "supersedes" | "relates";
|
|
4
|
-
export type Assignment = Readonly<{
|
|
5
|
-
field: "as" | "parent" | "pri" | RelationField;
|
|
6
|
-
operator: "=" | "+=" | "-=";
|
|
7
|
-
value: string;
|
|
8
|
-
line: number;
|
|
9
|
-
token: string;
|
|
10
|
-
}>;
|
|
11
|
-
export type BodyAssignment = Readonly<{
|
|
12
|
-
kind: "clear" | "replace";
|
|
13
|
-
value: string;
|
|
14
|
-
line: number;
|
|
15
|
-
token: string;
|
|
16
|
-
}>;
|
|
17
|
-
export type ParsedNode = Readonly<{
|
|
18
|
-
index: number;
|
|
19
|
-
line: number;
|
|
20
|
-
kind: "new" | "existing";
|
|
21
|
-
title?: string;
|
|
22
|
-
id?: TaskId;
|
|
23
|
-
assignments: readonly Assignment[];
|
|
24
|
-
body?: BodyAssignment;
|
|
25
|
-
}>;
|
|
26
|
-
export type ParsedComposition = Readonly<{
|
|
27
|
-
namespace?: readonly string[];
|
|
28
|
-
nodes: readonly ParsedNode[];
|
|
29
|
-
diagnostics: readonly TaskCompositionDiagnostic[];
|
|
30
|
-
}>;
|
|
31
|
-
export declare function parseTaskComposition(source: string): ParsedComposition;
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
import { parseTaskId } from "./identity.js";
|
|
2
|
-
function diagnostic(line, reason, token) {
|
|
3
|
-
return { line, reason, token };
|
|
4
|
-
}
|
|
5
|
-
function sourceLines(source) {
|
|
6
|
-
const lines = [];
|
|
7
|
-
let start = 0;
|
|
8
|
-
let number = 1;
|
|
9
|
-
while (start < source.length) {
|
|
10
|
-
let cursor = start;
|
|
11
|
-
while (cursor < source.length && source[cursor] !== "\n" && source[cursor] !== "\r")
|
|
12
|
-
cursor += 1;
|
|
13
|
-
let end = cursor;
|
|
14
|
-
if (source[cursor] === "\r" && source[cursor + 1] === "\n")
|
|
15
|
-
end += 2;
|
|
16
|
-
else if (cursor < source.length)
|
|
17
|
-
end += 1;
|
|
18
|
-
lines.push({ number, start, end, text: source.slice(start, cursor) });
|
|
19
|
-
start = end;
|
|
20
|
-
number += 1;
|
|
21
|
-
}
|
|
22
|
-
if (source.length === 0 || source.endsWith("\n") || source.endsWith("\r")) {
|
|
23
|
-
lines.push({ number, start: source.length, end: source.length, text: "" });
|
|
24
|
-
}
|
|
25
|
-
return lines;
|
|
26
|
-
}
|
|
27
|
-
function withoutFenceSeparator(value) {
|
|
28
|
-
if (value.endsWith("\r\n"))
|
|
29
|
-
return value.slice(0, -2);
|
|
30
|
-
if (value.endsWith("\n") || value.endsWith("\r"))
|
|
31
|
-
return value.slice(0, -1);
|
|
32
|
-
return value;
|
|
33
|
-
}
|
|
34
|
-
function parseNamespace(value) {
|
|
35
|
-
if (value === "")
|
|
36
|
-
return [];
|
|
37
|
-
return parseTaskId(`task/${value}/placeholder`).namespace;
|
|
38
|
-
}
|
|
39
|
-
function parseAssignment(text, line) {
|
|
40
|
-
const matched = /^(as|parent|pri|needs|supersedes|relates)\s*(=|\+=|-=)\s*(.*)$/u.exec(text);
|
|
41
|
-
if (matched === null)
|
|
42
|
-
return null;
|
|
43
|
-
return {
|
|
44
|
-
field: matched[1],
|
|
45
|
-
operator: matched[2],
|
|
46
|
-
value: matched[3],
|
|
47
|
-
line,
|
|
48
|
-
token: text,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
function parseNode(text, line, index) {
|
|
52
|
-
if (text.startsWith("+ ")) {
|
|
53
|
-
const title = text.slice(2);
|
|
54
|
-
return title.trim().length === 0
|
|
55
|
-
? diagnostic(line, "new task title must be nonblank", text)
|
|
56
|
-
: { index, line, kind: "new", title, assignments: [] };
|
|
57
|
-
}
|
|
58
|
-
if (!text.startsWith("@task/"))
|
|
59
|
-
return diagnostic(line, "node must begin with + or @task/", text);
|
|
60
|
-
try {
|
|
61
|
-
const id = text.slice(1);
|
|
62
|
-
parseTaskId(id);
|
|
63
|
-
return { index, line, kind: "existing", id, assignments: [] };
|
|
64
|
-
}
|
|
65
|
-
catch (error) {
|
|
66
|
-
return diagnostic(line, error instanceof Error ? error.message : String(error), text);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
function withAssignment(node, assignment) {
|
|
70
|
-
return { ...node, assignments: [...node.assignments, assignment] };
|
|
71
|
-
}
|
|
72
|
-
function withBody(node, body) {
|
|
73
|
-
return { ...node, body };
|
|
74
|
-
}
|
|
75
|
-
function replaceNode(nodes, node) {
|
|
76
|
-
nodes[node.index] = node;
|
|
77
|
-
}
|
|
78
|
-
export function parseTaskComposition(source) {
|
|
79
|
-
const lines = sourceLines(source);
|
|
80
|
-
const diagnostics = [];
|
|
81
|
-
const nodes = [];
|
|
82
|
-
let namespace;
|
|
83
|
-
let current;
|
|
84
|
-
let sawNode = false;
|
|
85
|
-
let sawNamespace = false;
|
|
86
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
87
|
-
const line = lines[index];
|
|
88
|
-
const text = line.text.trimStart();
|
|
89
|
-
if (text.trim().length === 0)
|
|
90
|
-
continue;
|
|
91
|
-
if (text.startsWith("ns=")) {
|
|
92
|
-
if (sawNode || sawNamespace)
|
|
93
|
-
diagnostics.push(diagnostic(line.number, "namespace must be the first nonblank line", text));
|
|
94
|
-
else {
|
|
95
|
-
try {
|
|
96
|
-
namespace = parseNamespace(text.slice(3));
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
diagnostics.push(diagnostic(line.number, error instanceof Error ? error.message : String(error), text));
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
sawNamespace = true;
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (text.startsWith("+ ") || text.startsWith("@task/")) {
|
|
106
|
-
const parsed = parseNode(text, line.number, nodes.length);
|
|
107
|
-
if ("reason" in parsed) {
|
|
108
|
-
diagnostics.push(parsed);
|
|
109
|
-
current = undefined;
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
nodes.push(parsed);
|
|
113
|
-
current = parsed;
|
|
114
|
-
}
|
|
115
|
-
sawNode = true;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (current === undefined) {
|
|
119
|
-
diagnostics.push(diagnostic(line.number, "property must follow a task node", text));
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
const fence = /^body\s+<<(.+)$/u.exec(text);
|
|
123
|
-
if (fence !== null) {
|
|
124
|
-
const token = fence[1];
|
|
125
|
-
if (!/^[A-Z][A-Z0-9_]{2,31}$/u.test(token)) {
|
|
126
|
-
diagnostics.push(diagnostic(line.number, "body token must match [A-Z][A-Z0-9_]* with length 3..32", token));
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const close = lines.findIndex((candidate, candidateIndex) => candidateIndex > index && candidate.text === token);
|
|
130
|
-
if (close < 0) {
|
|
131
|
-
diagnostics.push(diagnostic(line.number, "body fence is not closed", token));
|
|
132
|
-
break;
|
|
133
|
-
}
|
|
134
|
-
const body = withoutFenceSeparator(source.slice(line.end, lines[close].start));
|
|
135
|
-
if (current.body !== undefined)
|
|
136
|
-
diagnostics.push(diagnostic(line.number, "body is assigned more than once", text));
|
|
137
|
-
else {
|
|
138
|
-
current = withBody(current, { kind: "replace", value: body, line: line.number, token });
|
|
139
|
-
replaceNode(nodes, current);
|
|
140
|
-
}
|
|
141
|
-
index = close;
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
if (/^body\s*=\s*$/u.test(text)) {
|
|
145
|
-
if (current.body !== undefined)
|
|
146
|
-
diagnostics.push(diagnostic(line.number, "body is assigned more than once", text));
|
|
147
|
-
else {
|
|
148
|
-
current = withBody(current, { kind: "clear", value: "", line: line.number, token: text });
|
|
149
|
-
replaceNode(nodes, current);
|
|
150
|
-
}
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
const assignment = parseAssignment(text, line.number);
|
|
154
|
-
if (assignment === null)
|
|
155
|
-
diagnostics.push(diagnostic(line.number, "unknown compose property", text));
|
|
156
|
-
else {
|
|
157
|
-
current = withAssignment(current, assignment);
|
|
158
|
-
replaceNode(nodes, current);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return { ...(namespace === undefined ? {} : { namespace }), nodes, diagnostics };
|
|
162
|
-
}
|