@astralyn/pi 0.82.5 → 0.82.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +1 -1
- package/dist/cli/project-trust.d.ts.map +1 -1
- package/dist/cli/project-trust.js +4 -1
- package/dist/cli/project-trust.js.map +1 -1
- package/dist/cli/session-picker.d.ts.map +1 -1
- package/dist/cli/session-picker.js +11 -13
- package/dist/cli/session-picker.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +4 -22
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/types.d.ts +32 -5
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +2 -2
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/tools/bash.d.ts +1 -5
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +16 -41
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +42 -11
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +6 -7
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +17 -8
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/ls.d.ts.map +1 -1
- package/dist/core/tools/ls.js +3 -3
- package/dist/core/tools/ls.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts +3 -13
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +6 -36
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +4 -4
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/render-utils.d.ts +7 -0
- package/dist/core/tools/render-utils.d.ts.map +1 -1
- package/dist/core/tools/render-utils.js +11 -0
- package/dist/core/tools/render-utils.js.map +1 -1
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js +2 -3
- package/dist/core/tools/write.js.map +1 -1
- package/dist/extensions/deepwiki/render.d.ts.map +1 -1
- package/dist/extensions/deepwiki/render.js +34 -22
- package/dist/extensions/deepwiki/render.js.map +1 -1
- package/dist/extensions/plan/constants.d.ts +43 -6
- package/dist/extensions/plan/constants.d.ts.map +1 -1
- package/dist/extensions/plan/constants.js +51 -8
- package/dist/extensions/plan/constants.js.map +1 -1
- package/dist/extensions/plan/index.d.ts +12 -3
- package/dist/extensions/plan/index.d.ts.map +1 -1
- package/dist/extensions/plan/index.js +173 -35
- package/dist/extensions/plan/index.js.map +1 -1
- package/dist/extensions/plan/schema.d.ts +5 -0
- package/dist/extensions/plan/schema.d.ts.map +1 -1
- package/dist/extensions/plan/schema.js.map +1 -1
- package/dist/extensions/plan/state.d.ts +11 -2
- package/dist/extensions/plan/state.d.ts.map +1 -1
- package/dist/extensions/plan/state.js +31 -9
- package/dist/extensions/plan/state.js.map +1 -1
- package/dist/extensions/plan/storage.d.ts +11 -3
- package/dist/extensions/plan/storage.d.ts.map +1 -1
- package/dist/extensions/plan/storage.js +45 -20
- package/dist/extensions/plan/storage.js.map +1 -1
- package/dist/extensions/plan/view.d.ts +44 -0
- package/dist/extensions/plan/view.d.ts.map +1 -0
- package/dist/extensions/plan/view.js +149 -0
- package/dist/extensions/plan/view.js.map +1 -0
- package/dist/extensions/question/dialog.d.ts.map +1 -1
- package/dist/extensions/question/dialog.js +29 -10
- package/dist/extensions/question/dialog.js.map +1 -1
- package/dist/extensions/question/index.d.ts.map +1 -1
- package/dist/extensions/question/index.js +7 -2
- package/dist/extensions/question/index.js.map +1 -1
- package/dist/extensions/question/render.d.ts +4 -8
- package/dist/extensions/question/render.d.ts.map +1 -1
- package/dist/extensions/question/render.js +202 -29
- package/dist/extensions/question/render.js.map +1 -1
- package/dist/extensions/question/results.d.ts.map +1 -1
- package/dist/extensions/question/results.js +1 -0
- package/dist/extensions/question/results.js.map +1 -1
- package/dist/extensions/question/schema.d.ts.map +1 -1
- package/dist/extensions/question/schema.js +2 -2
- package/dist/extensions/question/schema.js.map +1 -1
- package/dist/extensions/question/types.d.ts +1 -0
- package/dist/extensions/question/types.d.ts.map +1 -1
- package/dist/extensions/question/types.js +1 -1
- package/dist/extensions/question/types.js.map +1 -1
- package/dist/extensions/router/stream.d.ts +1 -0
- package/dist/extensions/router/stream.d.ts.map +1 -1
- package/dist/extensions/router/stream.js +75 -0
- package/dist/extensions/router/stream.js.map +1 -1
- package/dist/extensions/subagent/activity.d.ts.map +1 -1
- package/dist/extensions/subagent/activity.js +25 -4
- package/dist/extensions/subagent/activity.js.map +1 -1
- package/dist/extensions/subagent/constants.d.ts +1 -0
- package/dist/extensions/subagent/constants.d.ts.map +1 -1
- package/dist/extensions/subagent/constants.js +1 -0
- package/dist/extensions/subagent/constants.js.map +1 -1
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +16 -0
- package/dist/extensions/subagent/index.js.map +1 -1
- package/dist/extensions/subagent/render.d.ts.map +1 -1
- package/dist/extensions/subagent/render.js +352 -152
- package/dist/extensions/subagent/render.js.map +1 -1
- package/dist/extensions/subagent/resolve.d.ts.map +1 -1
- package/dist/extensions/subagent/resolve.js +13 -9
- package/dist/extensions/subagent/resolve.js.map +1 -1
- package/dist/extensions/subagent/retry.d.ts +17 -0
- package/dist/extensions/subagent/retry.d.ts.map +1 -0
- package/dist/extensions/subagent/retry.js +41 -0
- package/dist/extensions/subagent/retry.js.map +1 -0
- package/dist/extensions/subagent/runner.d.ts.map +1 -1
- package/dist/extensions/subagent/runner.js +37 -7
- package/dist/extensions/subagent/runner.js.map +1 -1
- package/dist/extensions/subagent/schema.d.ts.map +1 -1
- package/dist/extensions/subagent/schema.js +2 -2
- package/dist/extensions/subagent/schema.js.map +1 -1
- package/dist/extensions/subagent/sdk-runner.d.ts +6 -0
- package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
- package/dist/extensions/subagent/sdk-runner.js +46 -24
- package/dist/extensions/subagent/sdk-runner.js.map +1 -1
- package/dist/extensions/subagent/types.d.ts +7 -0
- package/dist/extensions/subagent/types.d.ts.map +1 -1
- package/dist/extensions/subagent/types.js.map +1 -1
- package/dist/extensions/todo/constants.d.ts +17 -6
- package/dist/extensions/todo/constants.d.ts.map +1 -1
- package/dist/extensions/todo/constants.js +22 -9
- package/dist/extensions/todo/constants.js.map +1 -1
- package/dist/extensions/todo/index.d.ts +0 -14
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +13 -1
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +53 -2
- package/dist/extensions/todo/schema.d.ts.map +1 -1
- package/dist/extensions/todo/schema.js +57 -8
- package/dist/extensions/todo/schema.js.map +1 -1
- package/dist/extensions/todo/state.d.ts +16 -5
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +874 -130
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts +16 -1
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +428 -1
- package/dist/extensions/todo/view.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +2 -1
- package/dist/migrations.js.map +1 -1
- package/dist/modes/interactive/components/armin.d.ts.map +1 -1
- package/dist/modes/interactive/components/armin.js +5 -0
- package/dist/modes/interactive/components/armin.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts +1 -0
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js +4 -2
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/components/countdown-timer.d.ts +13 -6
- package/dist/modes/interactive/components/countdown-timer.d.ts.map +1 -1
- package/dist/modes/interactive/components/countdown-timer.js +54 -17
- package/dist/modes/interactive/components/countdown-timer.js.map +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts +6 -1
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js +23 -11
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.d.ts +11 -1
- package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.js +56 -0
- package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts +3 -0
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +52 -2
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts +10 -0
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +139 -18
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/components/tool-group.d.ts +1 -0
- package/dist/modes/interactive/components/tool-group.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-group.js +5 -0
- package/dist/modes/interactive/components/tool-group.js.map +1 -1
- package/dist/modes/interactive/components/tree-selector.d.ts +10 -1
- package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/tree-selector.js +55 -2
- package/dist/modes/interactive/components/tree-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +5 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +79 -19
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +3 -2
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +8 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/utils/paths.d.ts +6 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +8 -0
- package/dist/utils/paths.js.map +1 -1
- package/docs/bundled/README.md +1 -0
- package/docs/bundled/extensions/deepwiki.md +1 -1
- package/docs/bundled/extensions/plan.md +13 -10
- package/docs/bundled/extensions/question.md +7 -5
- package/docs/bundled/extensions/router.md +3 -4
- package/docs/bundled/extensions/statusline.md +0 -1
- package/docs/bundled/extensions/subagent.md +5 -4
- package/docs/bundled/extensions/todo.md +9 -7
- package/docs/bundled/tool-presentation.md +17 -16
- package/docs/extensions.md +34 -5
- package/docs/rpc.md +1 -1
- package/docs/usage.md +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +1 -1
- package/dist/utils/output-decoder.d.ts +0 -52
- package/dist/utils/output-decoder.d.ts.map +0 -1
- package/dist/utils/output-decoder.js +0 -134
- package/dist/utils/output-decoder.js.map +0 -1
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
import { LIST_DISPLAY_MAX_ITEMS, TODO_TOOL_NAME } from "./constants.js";
|
|
2
|
-
import { EMPTY_TODO_STATE, } from "./schema.js";
|
|
1
|
+
import { LIST_DISPLAY_MAX_ITEMS, TODO_MAX_ACTIVE_FORM_LENGTH, TODO_MAX_BATCH_ITEMS, TODO_MAX_BATCH_KEY_LENGTH, TODO_MAX_BLOCKED_BY, TODO_MAX_DESCRIPTION_LENGTH, TODO_MAX_LIST_LIMIT, TODO_MAX_LIST_QUERY_LENGTH, TODO_MAX_METADATA_BYTES, TODO_MAX_METADATA_DEPTH, TODO_MAX_METADATA_ENTRIES, TODO_MAX_METADATA_KEY_LENGTH, TODO_MAX_OWNER_LENGTH, TODO_MAX_SUBJECT_LENGTH, TODO_TOOL_NAME, } from "./constants.js";
|
|
2
|
+
import { EMPTY_TODO_STATE, TODO_DETAILS_SCHEMA_VERSION, } from "./schema.js";
|
|
3
3
|
/** Subjects/descriptions that count as a verification step (CC-style soft nudge). */
|
|
4
4
|
const VERIFICATION_PATTERN = /verif|test|check|review/i;
|
|
5
|
+
const TODO_STATUSES = new Set(["pending", "in_progress", "completed", "deleted"]);
|
|
6
|
+
const TODO_ACTIONS = new Set([
|
|
7
|
+
"create",
|
|
8
|
+
"create_many",
|
|
9
|
+
"update",
|
|
10
|
+
"list",
|
|
11
|
+
"get",
|
|
12
|
+
"delete",
|
|
13
|
+
"clear",
|
|
14
|
+
]);
|
|
15
|
+
const LEGACY_METADATA_LIMITS = {
|
|
16
|
+
maxDepth: 64,
|
|
17
|
+
maxEntries: 100_000,
|
|
18
|
+
maxBytes: 16 * 1024 * 1024,
|
|
19
|
+
};
|
|
5
20
|
// ---- per-session state ------------------------------------------------------
|
|
6
21
|
// Keyed by session id like rewind's engine state: one Pi process can host more
|
|
7
22
|
// than one session over its lifetime (resume, /tree branch switches), and a
|
|
@@ -32,6 +47,173 @@ export function disposeTodoSession(sid) {
|
|
|
32
47
|
if (activeSid === sid)
|
|
33
48
|
activeSid = "";
|
|
34
49
|
}
|
|
50
|
+
function isRecord(value) {
|
|
51
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
function isPlainRecord(value) {
|
|
54
|
+
if (!isRecord(value))
|
|
55
|
+
return false;
|
|
56
|
+
const prototype = Object.getPrototypeOf(value);
|
|
57
|
+
return prototype === Object.prototype || prototype === null;
|
|
58
|
+
}
|
|
59
|
+
function isPositiveInteger(value) {
|
|
60
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
61
|
+
}
|
|
62
|
+
function isTodoStatus(value) {
|
|
63
|
+
return typeof value === "string" && TODO_STATUSES.has(value);
|
|
64
|
+
}
|
|
65
|
+
function isTodoAction(value) {
|
|
66
|
+
return typeof value === "string" && TODO_ACTIONS.has(value);
|
|
67
|
+
}
|
|
68
|
+
function jsonStringUtf8Bytes(value) {
|
|
69
|
+
let bytes = 2; // JSON quotes
|
|
70
|
+
for (let index = 0; index < value.length; index++) {
|
|
71
|
+
const code = value.charCodeAt(index);
|
|
72
|
+
if (code === 0x22 ||
|
|
73
|
+
code === 0x5c ||
|
|
74
|
+
code === 0x08 ||
|
|
75
|
+
code === 0x0c ||
|
|
76
|
+
code === 0x0a ||
|
|
77
|
+
code === 0x0d ||
|
|
78
|
+
code === 0x09) {
|
|
79
|
+
bytes += 2;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (code <= 0x1f) {
|
|
83
|
+
bytes += 6;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
87
|
+
const next = value.charCodeAt(index + 1);
|
|
88
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
89
|
+
bytes += 4;
|
|
90
|
+
index++;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
bytes += 6; // JSON escapes lone surrogates.
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
98
|
+
bytes += 6;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
|
|
102
|
+
}
|
|
103
|
+
return bytes;
|
|
104
|
+
}
|
|
105
|
+
function addMetadataBytes(stats, amount, limits) {
|
|
106
|
+
stats.bytes += amount;
|
|
107
|
+
if (stats.bytes > limits.maxBytes)
|
|
108
|
+
throw new Error(`metadata exceeds ${limits.maxBytes} UTF-8 bytes`);
|
|
109
|
+
}
|
|
110
|
+
function addMetadataEntry(stats, limits) {
|
|
111
|
+
stats.entries++;
|
|
112
|
+
if (stats.entries > limits.maxEntries)
|
|
113
|
+
throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Validate and clone JSON-like values without using JSON.stringify as the
|
|
117
|
+
* validator. This rejects cycles, accessors, non-finite numbers, functions,
|
|
118
|
+
* symbols, bigint, undefined, and non-plain object instances.
|
|
119
|
+
*/
|
|
120
|
+
function cloneJsonValue(value, depth, ancestors, stats, limits) {
|
|
121
|
+
if (depth > limits.maxDepth)
|
|
122
|
+
throw new Error(`metadata exceeds depth ${limits.maxDepth}`);
|
|
123
|
+
if (value === null) {
|
|
124
|
+
addMetadataBytes(stats, 4, limits);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (typeof value === "string") {
|
|
128
|
+
addMetadataBytes(stats, jsonStringUtf8Bytes(value), limits);
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
if (typeof value === "boolean") {
|
|
132
|
+
addMetadataBytes(stats, value ? 4 : 5, limits);
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
if (typeof value === "number") {
|
|
136
|
+
if (!Number.isFinite(value))
|
|
137
|
+
throw new Error("metadata numbers must be finite");
|
|
138
|
+
addMetadataBytes(stats, Buffer.byteLength(String(value), "utf8"), limits);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
if (typeof value !== "object")
|
|
142
|
+
throw new Error("metadata must contain only JSON-like values");
|
|
143
|
+
if (ancestors.has(value))
|
|
144
|
+
throw new Error("metadata cannot contain cycles");
|
|
145
|
+
ancestors.add(value);
|
|
146
|
+
try {
|
|
147
|
+
if (Array.isArray(value)) {
|
|
148
|
+
if (value.length > limits.maxEntries - stats.entries) {
|
|
149
|
+
throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
|
|
150
|
+
}
|
|
151
|
+
const ownKeys = Object.keys(value);
|
|
152
|
+
if (ownKeys.length !== value.length)
|
|
153
|
+
throw new Error("metadata arrays cannot be sparse or have extra properties");
|
|
154
|
+
const copy = [];
|
|
155
|
+
addMetadataBytes(stats, 2, limits);
|
|
156
|
+
for (let index = 0; index < value.length; index++) {
|
|
157
|
+
addMetadataEntry(stats, limits);
|
|
158
|
+
if (index > 0)
|
|
159
|
+
addMetadataBytes(stats, 1, limits);
|
|
160
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
161
|
+
if (!descriptor || !("value" in descriptor))
|
|
162
|
+
throw new Error("metadata cannot contain accessors");
|
|
163
|
+
copy.push(cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits));
|
|
164
|
+
}
|
|
165
|
+
return copy;
|
|
166
|
+
}
|
|
167
|
+
if (!isPlainRecord(value))
|
|
168
|
+
throw new Error("metadata objects must be plain objects");
|
|
169
|
+
const copy = {};
|
|
170
|
+
const keys = Object.keys(value);
|
|
171
|
+
if (keys.length > limits.maxEntries - stats.entries) {
|
|
172
|
+
throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
|
|
173
|
+
}
|
|
174
|
+
addMetadataBytes(stats, 2, limits);
|
|
175
|
+
for (let index = 0; index < keys.length; index++) {
|
|
176
|
+
const key = keys[index];
|
|
177
|
+
if (limits.maxKeyLength !== undefined && key.length > limits.maxKeyLength) {
|
|
178
|
+
throw new Error(`metadata keys exceed ${limits.maxKeyLength} characters`);
|
|
179
|
+
}
|
|
180
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
181
|
+
if (!descriptor || !("value" in descriptor))
|
|
182
|
+
throw new Error("metadata cannot contain accessors");
|
|
183
|
+
addMetadataEntry(stats, limits);
|
|
184
|
+
if (index > 0)
|
|
185
|
+
addMetadataBytes(stats, 1, limits);
|
|
186
|
+
addMetadataBytes(stats, jsonStringUtf8Bytes(key) + 1, limits);
|
|
187
|
+
Object.defineProperty(copy, key, {
|
|
188
|
+
value: cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits),
|
|
189
|
+
enumerable: true,
|
|
190
|
+
writable: true,
|
|
191
|
+
configurable: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return copy;
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
ancestors.delete(value);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function validateAndCloneMetadata(value, limits) {
|
|
201
|
+
if (!isPlainRecord(value))
|
|
202
|
+
return { error: "metadata must be a plain object" };
|
|
203
|
+
try {
|
|
204
|
+
const cloned = cloneJsonValue(value, 1, new WeakSet(), { entries: 0, bytes: 0 }, limits);
|
|
205
|
+
if (!isPlainRecord(cloned))
|
|
206
|
+
return { error: "metadata must be a plain object" };
|
|
207
|
+
return { metadata: cloned };
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
return { error: error instanceof Error ? error.message : "metadata is invalid" };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function cloneStoredMetadata(value) {
|
|
214
|
+
const result = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);
|
|
215
|
+
return "metadata" in result ? result.metadata : undefined;
|
|
216
|
+
}
|
|
35
217
|
export function cloneState(source) {
|
|
36
218
|
return {
|
|
37
219
|
items: source.items.map((item) => {
|
|
@@ -40,8 +222,13 @@ export function cloneState(source) {
|
|
|
40
222
|
clone.blockedBy = [...item.blockedBy];
|
|
41
223
|
else
|
|
42
224
|
delete clone.blockedBy;
|
|
43
|
-
if (item.metadata)
|
|
44
|
-
|
|
225
|
+
if (item.metadata) {
|
|
226
|
+
const metadata = cloneStoredMetadata(item.metadata);
|
|
227
|
+
if (metadata)
|
|
228
|
+
clone.metadata = metadata;
|
|
229
|
+
else
|
|
230
|
+
delete clone.metadata;
|
|
231
|
+
}
|
|
45
232
|
else
|
|
46
233
|
delete clone.metadata;
|
|
47
234
|
return clone;
|
|
@@ -87,6 +274,7 @@ function error(state, message) {
|
|
|
87
274
|
// should learn about addBlockedBy, not lose the edit).
|
|
88
275
|
const ACTION_PARAMS = {
|
|
89
276
|
create: new Set(["subject", "description", "activeForm", "status", "blockedBy", "owner", "metadata"]),
|
|
277
|
+
create_many: new Set(["items"]),
|
|
90
278
|
update: new Set([
|
|
91
279
|
"id",
|
|
92
280
|
"subject",
|
|
@@ -100,10 +288,10 @@ const ACTION_PARAMS = {
|
|
|
100
288
|
"owner",
|
|
101
289
|
"metadata",
|
|
102
290
|
]),
|
|
103
|
-
list: new Set(["status", "includeDeleted"]),
|
|
291
|
+
list: new Set(["status", "includeDeleted", "limit", "afterId", "query", "unblockedOnly"]),
|
|
104
292
|
get: new Set(["id"]),
|
|
105
293
|
delete: new Set(["id"]),
|
|
106
|
-
clear: new Set(),
|
|
294
|
+
clear: new Set(["confirm", "expectedCount"]),
|
|
107
295
|
};
|
|
108
296
|
const UPDATE_ONLY_EDGE_PARAMS = new Set(["addBlockedBy", "removeBlockedBy", "addBlocks", "removeBlocks"]);
|
|
109
297
|
function inapplicableParamError(action, key) {
|
|
@@ -125,6 +313,57 @@ function findInapplicableParam(params) {
|
|
|
125
313
|
}
|
|
126
314
|
return undefined;
|
|
127
315
|
}
|
|
316
|
+
function validateBoundedString(value, label, maximum) {
|
|
317
|
+
if (typeof value !== "string")
|
|
318
|
+
return `${label} must be a string`;
|
|
319
|
+
if (value.length > maximum)
|
|
320
|
+
return `${label} exceeds ${maximum} characters`;
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
function validateTaskTextFields(params, requireDescription) {
|
|
324
|
+
if (params.subject !== undefined) {
|
|
325
|
+
const subjectError = validateBoundedString(params.subject, "subject", TODO_MAX_SUBJECT_LENGTH);
|
|
326
|
+
if (subjectError)
|
|
327
|
+
return subjectError;
|
|
328
|
+
}
|
|
329
|
+
if (params.description !== undefined) {
|
|
330
|
+
const descriptionError = validateBoundedString(params.description, "description", TODO_MAX_DESCRIPTION_LENGTH);
|
|
331
|
+
if (descriptionError)
|
|
332
|
+
return descriptionError;
|
|
333
|
+
}
|
|
334
|
+
if (params.activeForm !== undefined) {
|
|
335
|
+
const activeFormError = validateBoundedString(params.activeForm, "activeForm", TODO_MAX_ACTIVE_FORM_LENGTH);
|
|
336
|
+
if (activeFormError)
|
|
337
|
+
return activeFormError;
|
|
338
|
+
}
|
|
339
|
+
if (params.owner !== undefined) {
|
|
340
|
+
const ownerError = validateBoundedString(params.owner, "owner", TODO_MAX_OWNER_LENGTH);
|
|
341
|
+
if (ownerError)
|
|
342
|
+
return ownerError;
|
|
343
|
+
}
|
|
344
|
+
if (params.subject !== undefined && !params.subject.trim())
|
|
345
|
+
return "subject cannot be empty";
|
|
346
|
+
if (requireDescription && !params.description?.trim()) {
|
|
347
|
+
return "description required for create: state what done means for this task";
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
function validateIdArray(value, label) {
|
|
352
|
+
if (value === undefined)
|
|
353
|
+
return [];
|
|
354
|
+
if (!Array.isArray(value))
|
|
355
|
+
return `${label} must be an array`;
|
|
356
|
+
if (value.length > TODO_MAX_BLOCKED_BY)
|
|
357
|
+
return `${label} exceeds ${TODO_MAX_BLOCKED_BY} ids`;
|
|
358
|
+
const ids = [];
|
|
359
|
+
for (const id of value) {
|
|
360
|
+
if (!isPositiveInteger(id))
|
|
361
|
+
return `${label} must contain positive integer ids`;
|
|
362
|
+
if (!ids.includes(id))
|
|
363
|
+
ids.push(id);
|
|
364
|
+
}
|
|
365
|
+
return ids;
|
|
366
|
+
}
|
|
128
367
|
function isTransitionAllowed(from, to) {
|
|
129
368
|
// `deleted` is the only terminal status; `completed` can be reopened to
|
|
130
369
|
// in_progress or pending so a premature completion recovers without losing
|
|
@@ -147,8 +386,6 @@ function findItem(state, id) {
|
|
|
147
386
|
return state.items.find((item) => item.id === id);
|
|
148
387
|
}
|
|
149
388
|
function validateDependencies(state, deps, currentId) {
|
|
150
|
-
if (!deps?.length)
|
|
151
|
-
return undefined;
|
|
152
389
|
for (const dep of deps) {
|
|
153
390
|
if (dep === currentId)
|
|
154
391
|
return `cannot block #${currentId} on itself`;
|
|
@@ -162,8 +399,6 @@ function validateDependencies(state, deps, currentId) {
|
|
|
162
399
|
}
|
|
163
400
|
/** All listed dependencies must be completed before starting or closing a task. */
|
|
164
401
|
function validateDependenciesReady(state, deps) {
|
|
165
|
-
if (!deps?.length)
|
|
166
|
-
return undefined;
|
|
167
402
|
for (const dep of deps) {
|
|
168
403
|
const item = findItem(state, dep);
|
|
169
404
|
if (!item || item.status !== "completed") {
|
|
@@ -180,75 +415,255 @@ export function unresolvedDependencyIds(state, item) {
|
|
|
180
415
|
export function dependenciesSatisfied(state, item) {
|
|
181
416
|
return unresolvedDependencyIds(state, item).length === 0;
|
|
182
417
|
}
|
|
183
|
-
/** True when
|
|
418
|
+
/** True when the resulting dependency graph contains a cycle. */
|
|
184
419
|
function createsCycle(items, nextDepsById) {
|
|
185
420
|
const depsById = new Map();
|
|
186
421
|
for (const item of items)
|
|
187
422
|
depsById.set(item.id, item.blockedBy ?? []);
|
|
188
423
|
for (const [id, deps] of nextDepsById)
|
|
189
424
|
depsById.set(id, deps);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
stack.
|
|
199
|
-
|
|
200
|
-
|
|
425
|
+
// An iterative color walk keeps replay and dependency edits safe for long,
|
|
426
|
+
// valid chains that would overflow the JavaScript call stack recursively.
|
|
427
|
+
const colors = new Map();
|
|
428
|
+
const stack = [];
|
|
429
|
+
for (const initialId of depsById.keys()) {
|
|
430
|
+
if ((colors.get(initialId) ?? 0) !== 0)
|
|
431
|
+
continue;
|
|
432
|
+
colors.set(initialId, 1);
|
|
433
|
+
stack.push({ id: initialId, nextDependencyIndex: 0 });
|
|
434
|
+
while (stack.length) {
|
|
435
|
+
const frame = stack[stack.length - 1];
|
|
436
|
+
const dependencies = depsById.get(frame.id) ?? [];
|
|
437
|
+
if (frame.nextDependencyIndex >= dependencies.length) {
|
|
438
|
+
colors.set(frame.id, 2);
|
|
439
|
+
stack.pop();
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const dependencyId = dependencies[frame.nextDependencyIndex];
|
|
443
|
+
frame.nextDependencyIndex++;
|
|
444
|
+
if (!depsById.has(dependencyId))
|
|
445
|
+
continue;
|
|
446
|
+
const color = colors.get(dependencyId) ?? 0;
|
|
447
|
+
if (color === 1)
|
|
201
448
|
return true;
|
|
449
|
+
if (color === 2)
|
|
450
|
+
continue;
|
|
451
|
+
colors.set(dependencyId, 1);
|
|
452
|
+
stack.push({ id: dependencyId, nextDependencyIndex: 0 });
|
|
202
453
|
}
|
|
203
|
-
stack.delete(node);
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
for (const id of nextDepsById.keys()) {
|
|
207
|
-
if (visit(id))
|
|
208
|
-
return true;
|
|
209
454
|
}
|
|
210
455
|
return false;
|
|
211
456
|
}
|
|
457
|
+
function validateAndNormalizeBatch(params, state) {
|
|
458
|
+
const rawItems = params.items;
|
|
459
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0)
|
|
460
|
+
return "items required for create_many";
|
|
461
|
+
if (rawItems.length > TODO_MAX_BATCH_ITEMS)
|
|
462
|
+
return `items exceeds ${TODO_MAX_BATCH_ITEMS} tasks`;
|
|
463
|
+
const allowedKeys = new Set([
|
|
464
|
+
"key",
|
|
465
|
+
"subject",
|
|
466
|
+
"description",
|
|
467
|
+
"activeForm",
|
|
468
|
+
"blockedBy",
|
|
469
|
+
"blockedByKeys",
|
|
470
|
+
"owner",
|
|
471
|
+
"metadata",
|
|
472
|
+
]);
|
|
473
|
+
const batch = [];
|
|
474
|
+
const keys = new Map();
|
|
475
|
+
for (let index = 0; index < rawItems.length; index++) {
|
|
476
|
+
const raw = rawItems[index];
|
|
477
|
+
if (!isPlainRecord(raw))
|
|
478
|
+
return `items[${index}] must be an object`;
|
|
479
|
+
for (const property of Object.keys(raw)) {
|
|
480
|
+
if (!allowedKeys.has(property))
|
|
481
|
+
return `items[${index}].${property} does not apply to create_many`;
|
|
482
|
+
}
|
|
483
|
+
const taskParams = raw;
|
|
484
|
+
if (typeof raw.subject !== "string" || !raw.subject.trim())
|
|
485
|
+
return `items[${index}]: subject required for create_many`;
|
|
486
|
+
const taskTextError = validateTaskTextFields(taskParams, true);
|
|
487
|
+
if (taskTextError)
|
|
488
|
+
return `items[${index}]: ${taskTextError}`;
|
|
489
|
+
let key;
|
|
490
|
+
if (raw.key !== undefined) {
|
|
491
|
+
const keyError = validateBoundedString(raw.key, "key", TODO_MAX_BATCH_KEY_LENGTH);
|
|
492
|
+
if (keyError)
|
|
493
|
+
return `items[${index}]: ${keyError}`;
|
|
494
|
+
key = raw.key.trim();
|
|
495
|
+
if (!key)
|
|
496
|
+
return `items[${index}]: key cannot be empty`;
|
|
497
|
+
if (keys.has(key))
|
|
498
|
+
return `items[${index}]: duplicate key ${key}`;
|
|
499
|
+
keys.set(key, index);
|
|
500
|
+
}
|
|
501
|
+
const blockedBy = validateIdArray(raw.blockedBy, `items[${index}].blockedBy`);
|
|
502
|
+
if (typeof blockedBy === "string")
|
|
503
|
+
return blockedBy;
|
|
504
|
+
const blockedByError = validateDependencies(state, blockedBy);
|
|
505
|
+
if (blockedByError)
|
|
506
|
+
return `items[${index}]: ${blockedByError}`;
|
|
507
|
+
const rawBlockedByKeys = raw.blockedByKeys;
|
|
508
|
+
if (rawBlockedByKeys !== undefined && !Array.isArray(rawBlockedByKeys)) {
|
|
509
|
+
return `items[${index}].blockedByKeys must be an array`;
|
|
510
|
+
}
|
|
511
|
+
if (Array.isArray(rawBlockedByKeys) && rawBlockedByKeys.length > TODO_MAX_BLOCKED_BY) {
|
|
512
|
+
return `items[${index}].blockedByKeys exceeds ${TODO_MAX_BLOCKED_BY} keys`;
|
|
513
|
+
}
|
|
514
|
+
const blockedByKeys = [];
|
|
515
|
+
for (const reference of rawBlockedByKeys ?? []) {
|
|
516
|
+
const referenceError = validateBoundedString(reference, "blockedByKeys", TODO_MAX_BATCH_KEY_LENGTH);
|
|
517
|
+
if (referenceError)
|
|
518
|
+
return `items[${index}]: ${referenceError}`;
|
|
519
|
+
const normalizedReference = reference.trim();
|
|
520
|
+
if (!normalizedReference)
|
|
521
|
+
return `items[${index}]: blockedByKeys cannot contain an empty key`;
|
|
522
|
+
if (!blockedByKeys.includes(normalizedReference))
|
|
523
|
+
blockedByKeys.push(normalizedReference);
|
|
524
|
+
}
|
|
525
|
+
let metadata;
|
|
526
|
+
if (raw.metadata !== undefined) {
|
|
527
|
+
const metadataResult = validateAndCloneMetadata(raw.metadata, {
|
|
528
|
+
maxDepth: TODO_MAX_METADATA_DEPTH,
|
|
529
|
+
maxEntries: TODO_MAX_METADATA_ENTRIES,
|
|
530
|
+
maxBytes: TODO_MAX_METADATA_BYTES,
|
|
531
|
+
maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
|
|
532
|
+
});
|
|
533
|
+
if ("error" in metadataResult)
|
|
534
|
+
return `items[${index}]: ${metadataResult.error}`;
|
|
535
|
+
metadata = metadataResult.metadata;
|
|
536
|
+
}
|
|
537
|
+
batch.push({
|
|
538
|
+
...(key ? { key } : {}),
|
|
539
|
+
subject: raw.subject.trim(),
|
|
540
|
+
description: raw.description,
|
|
541
|
+
...(raw.activeForm ? { activeForm: raw.activeForm } : {}),
|
|
542
|
+
blockedBy,
|
|
543
|
+
blockedByKeys,
|
|
544
|
+
...(raw.owner ? { owner: raw.owner } : {}),
|
|
545
|
+
...(metadata ? { metadata } : {}),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
for (let index = 0; index < batch.length; index++) {
|
|
549
|
+
for (const key of batch[index].blockedByKeys) {
|
|
550
|
+
if (!keys.has(key))
|
|
551
|
+
return `items[${index}]: blockedByKeys references missing key ${key}`;
|
|
552
|
+
if (key === batch[index].key)
|
|
553
|
+
return `items[${index}]: cannot block a task on itself`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return batch;
|
|
557
|
+
}
|
|
558
|
+
function createMany(state, params) {
|
|
559
|
+
const batch = validateAndNormalizeBatch(params, state);
|
|
560
|
+
if (typeof batch === "string")
|
|
561
|
+
return error(state, batch);
|
|
562
|
+
if (!Number.isSafeInteger(state.nextId) ||
|
|
563
|
+
state.nextId < 1 ||
|
|
564
|
+
state.nextId + batch.length - 1 > Number.MAX_SAFE_INTEGER) {
|
|
565
|
+
return error(state, "next id is invalid or exhausted");
|
|
566
|
+
}
|
|
567
|
+
const idsByKey = new Map();
|
|
568
|
+
for (let index = 0; index < batch.length; index++) {
|
|
569
|
+
const key = batch[index].key;
|
|
570
|
+
if (key)
|
|
571
|
+
idsByKey.set(key, state.nextId + index);
|
|
572
|
+
}
|
|
573
|
+
const created = batch.map((source, index) => {
|
|
574
|
+
const blockedBy = [...source.blockedBy];
|
|
575
|
+
for (const key of source.blockedByKeys) {
|
|
576
|
+
const id = idsByKey.get(key);
|
|
577
|
+
if (id !== undefined && !blockedBy.includes(id))
|
|
578
|
+
blockedBy.push(id);
|
|
579
|
+
}
|
|
580
|
+
const item = {
|
|
581
|
+
id: state.nextId + index,
|
|
582
|
+
subject: source.subject,
|
|
583
|
+
description: source.description,
|
|
584
|
+
status: "pending",
|
|
585
|
+
};
|
|
586
|
+
if (source.activeForm)
|
|
587
|
+
item.activeForm = source.activeForm;
|
|
588
|
+
if (blockedBy.length)
|
|
589
|
+
item.blockedBy = blockedBy;
|
|
590
|
+
if (source.owner)
|
|
591
|
+
item.owner = source.owner;
|
|
592
|
+
if (source.metadata)
|
|
593
|
+
item.metadata = source.metadata;
|
|
594
|
+
return item;
|
|
595
|
+
});
|
|
596
|
+
const batchEdges = new Map();
|
|
597
|
+
for (const item of created)
|
|
598
|
+
batchEdges.set(item.id, item.blockedBy ?? []);
|
|
599
|
+
if (createsCycle([...state.items, ...created], batchEdges))
|
|
600
|
+
return error(state, "blockedBy would create a cycle");
|
|
601
|
+
return {
|
|
602
|
+
state: { items: [...state.items, ...created], nextId: state.nextId + created.length },
|
|
603
|
+
operation: { kind: "create_many", ids: created.map((item) => item.id) },
|
|
604
|
+
};
|
|
605
|
+
}
|
|
212
606
|
export function applyTodoMutation(input, params) {
|
|
213
607
|
const state = cloneState(input);
|
|
608
|
+
if (!isTodoAction(params.action))
|
|
609
|
+
return error(state, `unknown action: ${String(params.action)}`);
|
|
214
610
|
const inapplicable = findInapplicableParam(params);
|
|
215
611
|
if (inapplicable)
|
|
216
612
|
return error(state, inapplicable);
|
|
217
613
|
switch (params.action) {
|
|
218
614
|
case "create": {
|
|
219
|
-
|
|
220
|
-
if (!subject)
|
|
615
|
+
if (typeof params.subject !== "string" || !params.subject.trim())
|
|
221
616
|
return error(state, "subject required for create");
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
617
|
+
const taskTextError = validateTaskTextFields(params, true);
|
|
618
|
+
if (taskTextError)
|
|
619
|
+
return error(state, taskTextError);
|
|
225
620
|
if (params.status !== undefined && params.status !== "pending") {
|
|
226
621
|
return error(state, "create always starts pending; use update to start or complete a task");
|
|
227
622
|
}
|
|
228
|
-
const
|
|
623
|
+
const blockedBy = validateIdArray(params.blockedBy, "blockedBy");
|
|
624
|
+
if (typeof blockedBy === "string")
|
|
625
|
+
return error(state, blockedBy);
|
|
626
|
+
const dependencyError = validateDependencies(state, blockedBy);
|
|
229
627
|
if (dependencyError)
|
|
230
628
|
return error(state, dependencyError);
|
|
629
|
+
let metadata;
|
|
630
|
+
if (params.metadata !== undefined) {
|
|
631
|
+
const metadataResult = validateAndCloneMetadata(params.metadata, {
|
|
632
|
+
maxDepth: TODO_MAX_METADATA_DEPTH,
|
|
633
|
+
maxEntries: TODO_MAX_METADATA_ENTRIES,
|
|
634
|
+
maxBytes: TODO_MAX_METADATA_BYTES,
|
|
635
|
+
maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
|
|
636
|
+
});
|
|
637
|
+
if ("error" in metadataResult)
|
|
638
|
+
return error(state, metadataResult.error);
|
|
639
|
+
metadata = metadataResult.metadata;
|
|
640
|
+
}
|
|
641
|
+
if (!Number.isSafeInteger(state.nextId) || state.nextId < 1 || state.nextId === Number.MAX_SAFE_INTEGER) {
|
|
642
|
+
return error(state, "next id is invalid or exhausted");
|
|
643
|
+
}
|
|
231
644
|
const item = {
|
|
232
645
|
id: state.nextId,
|
|
233
|
-
subject,
|
|
646
|
+
subject: params.subject.trim(),
|
|
234
647
|
status: "pending",
|
|
235
648
|
description: params.description,
|
|
236
649
|
};
|
|
237
650
|
if (params.activeForm)
|
|
238
651
|
item.activeForm = params.activeForm;
|
|
239
|
-
if (
|
|
240
|
-
item.blockedBy =
|
|
652
|
+
if (blockedBy.length)
|
|
653
|
+
item.blockedBy = blockedBy;
|
|
241
654
|
if (params.owner)
|
|
242
655
|
item.owner = params.owner;
|
|
243
|
-
if (
|
|
244
|
-
item.metadata =
|
|
656
|
+
if (metadata)
|
|
657
|
+
item.metadata = metadata;
|
|
245
658
|
return {
|
|
246
659
|
state: { items: [...state.items, item], nextId: state.nextId + 1 },
|
|
247
660
|
operation: { kind: "create", id: item.id },
|
|
248
661
|
};
|
|
249
662
|
}
|
|
663
|
+
case "create_many":
|
|
664
|
+
return createMany(state, params);
|
|
250
665
|
case "update": {
|
|
251
|
-
if (params.id
|
|
666
|
+
if (!isPositiveInteger(params.id))
|
|
252
667
|
return error(state, "id required for update");
|
|
253
668
|
const index = state.items.findIndex((item) => item.id === params.id);
|
|
254
669
|
if (index === -1)
|
|
@@ -256,12 +671,24 @@ export function applyTodoMutation(input, params) {
|
|
|
256
671
|
const current = state.items[index];
|
|
257
672
|
if (current.status === "deleted")
|
|
258
673
|
return error(state, `#${params.id} is deleted and cannot be updated`);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
(
|
|
264
|
-
|
|
674
|
+
const taskTextError = validateTaskTextFields(params, false);
|
|
675
|
+
if (taskTextError)
|
|
676
|
+
return error(state, taskTextError);
|
|
677
|
+
if (params.status !== undefined && !isTodoStatus(params.status))
|
|
678
|
+
return error(state, "status is invalid");
|
|
679
|
+
const addBlockedBy = validateIdArray(params.addBlockedBy, "addBlockedBy");
|
|
680
|
+
const removeBlockedBy = validateIdArray(params.removeBlockedBy, "removeBlockedBy");
|
|
681
|
+
const addBlocks = validateIdArray(params.addBlocks, "addBlocks");
|
|
682
|
+
const removeBlocks = validateIdArray(params.removeBlocks, "removeBlocks");
|
|
683
|
+
if (typeof addBlockedBy === "string")
|
|
684
|
+
return error(state, addBlockedBy);
|
|
685
|
+
if (typeof removeBlockedBy === "string")
|
|
686
|
+
return error(state, removeBlockedBy);
|
|
687
|
+
if (typeof addBlocks === "string")
|
|
688
|
+
return error(state, addBlocks);
|
|
689
|
+
if (typeof removeBlocks === "string")
|
|
690
|
+
return error(state, removeBlocks);
|
|
691
|
+
const edgeChange = addBlockedBy.length > 0 || removeBlockedBy.length > 0 || addBlocks.length > 0 || removeBlocks.length > 0;
|
|
265
692
|
const fieldChange = params.subject !== undefined ||
|
|
266
693
|
params.description !== undefined ||
|
|
267
694
|
params.activeForm !== undefined ||
|
|
@@ -270,10 +697,6 @@ export function applyTodoMutation(input, params) {
|
|
|
270
697
|
if (!fieldChange && !edgeChange && params.status === undefined) {
|
|
271
698
|
return error(state, "update requires at least one field");
|
|
272
699
|
}
|
|
273
|
-
// `status: deleted` is the update form of delete; keep its dependency
|
|
274
|
-
// cleanup identical to the dedicated delete action. Other edits do not
|
|
275
|
-
// combine with it: a tombstone is immutable, so applying them would
|
|
276
|
-
// silently produce an unreachable revision.
|
|
277
700
|
if (params.status === "deleted") {
|
|
278
701
|
if (fieldChange || edgeChange) {
|
|
279
702
|
return error(state, "status deleted cannot be combined with other changes; apply edits first or delete in a separate call");
|
|
@@ -294,34 +717,65 @@ export function applyTodoMutation(input, params) {
|
|
|
294
717
|
if (!isTransitionAllowed(current.status, nextStatus)) {
|
|
295
718
|
return error(state, `illegal transition ${current.status} -> ${nextStatus}`);
|
|
296
719
|
}
|
|
720
|
+
let metadata;
|
|
721
|
+
if (params.metadata !== undefined) {
|
|
722
|
+
const patchResult = validateAndCloneMetadata(params.metadata, {
|
|
723
|
+
maxDepth: TODO_MAX_METADATA_DEPTH,
|
|
724
|
+
maxEntries: TODO_MAX_METADATA_ENTRIES,
|
|
725
|
+
maxBytes: TODO_MAX_METADATA_BYTES,
|
|
726
|
+
maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
|
|
727
|
+
});
|
|
728
|
+
if ("error" in patchResult)
|
|
729
|
+
return error(state, patchResult.error);
|
|
730
|
+
const merged = Object.create(null);
|
|
731
|
+
for (const [key, value] of Object.entries(current.metadata ?? {}))
|
|
732
|
+
merged[key] = value;
|
|
733
|
+
for (const [key, value] of Object.entries(patchResult.metadata)) {
|
|
734
|
+
if (value === null)
|
|
735
|
+
delete merged[key];
|
|
736
|
+
else
|
|
737
|
+
merged[key] = value;
|
|
738
|
+
}
|
|
739
|
+
if (Object.keys(merged).length) {
|
|
740
|
+
const mergedResult = validateAndCloneMetadata(merged, {
|
|
741
|
+
maxDepth: TODO_MAX_METADATA_DEPTH,
|
|
742
|
+
maxEntries: TODO_MAX_METADATA_ENTRIES,
|
|
743
|
+
maxBytes: TODO_MAX_METADATA_BYTES,
|
|
744
|
+
maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
|
|
745
|
+
});
|
|
746
|
+
if ("error" in mergedResult)
|
|
747
|
+
return error(state, mergedResult.error);
|
|
748
|
+
metadata = mergedResult.metadata;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
297
751
|
// Collect every task whose blockedBy changes: this task (addBlockedBy /
|
|
298
752
|
// removeBlockedBy) plus reverse-edge targets (addBlocks / removeBlocks).
|
|
299
753
|
const nextDepsById = new Map();
|
|
300
754
|
let nextDeps = current.blockedBy ? [...current.blockedBy] : [];
|
|
301
|
-
if (
|
|
302
|
-
const remove = new Set(
|
|
755
|
+
if (removeBlockedBy.length) {
|
|
756
|
+
const remove = new Set(removeBlockedBy);
|
|
303
757
|
nextDeps = nextDeps.filter((id) => !remove.has(id));
|
|
304
758
|
}
|
|
305
|
-
if (
|
|
306
|
-
const dependencyError = validateDependencies(state,
|
|
759
|
+
if (addBlockedBy.length) {
|
|
760
|
+
const dependencyError = validateDependencies(state, addBlockedBy, current.id);
|
|
307
761
|
if (dependencyError)
|
|
308
762
|
return error(state, dependencyError);
|
|
309
|
-
for (const dep of
|
|
763
|
+
for (const dep of addBlockedBy) {
|
|
310
764
|
if (!nextDeps.includes(dep))
|
|
311
765
|
nextDeps.push(dep);
|
|
312
766
|
}
|
|
313
767
|
}
|
|
314
768
|
nextDepsById.set(current.id, nextDeps);
|
|
315
|
-
if (
|
|
316
|
-
const release = new Set(
|
|
769
|
+
if (removeBlocks.length) {
|
|
770
|
+
const release = new Set(removeBlocks);
|
|
317
771
|
for (const candidate of state.items) {
|
|
318
772
|
if (!release.has(candidate.id) || !candidate.blockedBy?.includes(current.id))
|
|
319
773
|
continue;
|
|
320
774
|
nextDepsById.set(candidate.id, candidate.blockedBy.filter((dependencyId) => dependencyId !== current.id));
|
|
321
775
|
}
|
|
322
776
|
}
|
|
323
|
-
if (
|
|
324
|
-
for (const targetId of
|
|
777
|
+
if (addBlocks.length) {
|
|
778
|
+
for (const targetId of addBlocks) {
|
|
325
779
|
if (targetId === current.id)
|
|
326
780
|
return error(state, `cannot block #${targetId} on itself`);
|
|
327
781
|
const target = findItem(state, targetId);
|
|
@@ -338,9 +792,8 @@ export function applyTodoMutation(input, params) {
|
|
|
338
792
|
if (createsCycle(state.items, nextDepsById)) {
|
|
339
793
|
return error(state, "blockedBy would create a cycle");
|
|
340
794
|
}
|
|
341
|
-
// Readiness gates
|
|
342
|
-
// on a new incomplete
|
|
343
|
-
// The overlay flags it as "deps incomplete" instead.
|
|
795
|
+
// Readiness gates explicit status changes. An already active task that
|
|
796
|
+
// takes on a new incomplete edge is instead automatically demoted below.
|
|
344
797
|
if ((nextStatus === "in_progress" || nextStatus === "completed") && current.status !== nextStatus) {
|
|
345
798
|
const readyError = validateDependenciesReady(state, nextDeps);
|
|
346
799
|
if (readyError)
|
|
@@ -373,65 +826,107 @@ export function applyTodoMutation(input, params) {
|
|
|
373
826
|
else
|
|
374
827
|
delete updated.blockedBy;
|
|
375
828
|
if (params.metadata !== undefined) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (value === null)
|
|
379
|
-
delete merged[key];
|
|
380
|
-
else
|
|
381
|
-
merged[key] = value;
|
|
382
|
-
}
|
|
383
|
-
if (Object.keys(merged).length)
|
|
384
|
-
updated.metadata = merged;
|
|
829
|
+
if (metadata)
|
|
830
|
+
updated.metadata = metadata;
|
|
385
831
|
else
|
|
386
832
|
delete updated.metadata;
|
|
387
833
|
}
|
|
388
834
|
const items = state.items.map((task) => {
|
|
389
835
|
if (task.id === current.id)
|
|
390
|
-
return
|
|
836
|
+
return updated;
|
|
391
837
|
const deps = nextDepsById.get(task.id);
|
|
392
838
|
if (deps === undefined)
|
|
393
839
|
return task;
|
|
394
840
|
return deps.length ? { ...task, blockedBy: deps } : omitBlockedBy(task);
|
|
395
841
|
});
|
|
396
|
-
|
|
842
|
+
// A task that was already active may become newly blocked through either
|
|
843
|
+
// direction of an edge edit. Keep it out of in_progress and distinguish
|
|
844
|
+
// this from the usual single-active-task demotion in the result.
|
|
845
|
+
const blockedIds = [];
|
|
846
|
+
for (const [targetId, deps] of nextDepsById) {
|
|
847
|
+
const before = findItem(state, targetId);
|
|
848
|
+
const targetIndex = items.findIndex((item) => item.id === targetId);
|
|
849
|
+
if (!before || targetIndex === -1 || items[targetIndex].status !== "in_progress")
|
|
850
|
+
continue;
|
|
851
|
+
const previousDeps = before.blockedBy ?? [];
|
|
852
|
+
const gainedUnresolved = deps.some((dependencyId) => {
|
|
853
|
+
if (previousDeps.includes(dependencyId))
|
|
854
|
+
return false;
|
|
855
|
+
const dependency = items.find((item) => item.id === dependencyId);
|
|
856
|
+
return dependency?.status !== "completed";
|
|
857
|
+
});
|
|
858
|
+
if (gainedUnresolved) {
|
|
859
|
+
items[targetIndex] = { ...items[targetIndex], status: "pending" };
|
|
860
|
+
blockedIds.push(targetId);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
397
863
|
// Exactly one in_progress: demote any other active tasks to pending.
|
|
398
864
|
const demotedIds = [];
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (item.status !== "in_progress")
|
|
865
|
+
const finalCurrent = items[index];
|
|
866
|
+
if (nextStatus === "in_progress" && finalCurrent.status === "in_progress") {
|
|
867
|
+
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
|
868
|
+
const item = items[itemIndex];
|
|
869
|
+
if (item.id === finalCurrent.id || item.status !== "in_progress")
|
|
405
870
|
continue;
|
|
406
|
-
items[
|
|
871
|
+
items[itemIndex] = { ...item, status: "pending" };
|
|
407
872
|
demotedIds.push(item.id);
|
|
408
873
|
}
|
|
409
874
|
}
|
|
410
875
|
const nextState = { items, nextId: state.nextId };
|
|
411
|
-
const
|
|
876
|
+
const finalItem = items[index];
|
|
877
|
+
const newlyCompleted = current.status !== "completed" && finalItem.status === "completed";
|
|
412
878
|
return {
|
|
413
879
|
state: nextState,
|
|
414
880
|
operation: {
|
|
415
881
|
kind: "update",
|
|
416
|
-
id:
|
|
882
|
+
id: finalItem.id,
|
|
417
883
|
from: current.status,
|
|
418
|
-
to:
|
|
884
|
+
to: finalItem.status,
|
|
419
885
|
...(demotedIds.length ? { demotedIds } : {}),
|
|
886
|
+
...(blockedIds.length ? { blockedIds } : {}),
|
|
420
887
|
...(newlyCompleted && needsVerificationNudge(nextState) ? { verificationNudge: true } : {}),
|
|
421
888
|
},
|
|
422
889
|
};
|
|
423
890
|
}
|
|
424
|
-
case "list":
|
|
891
|
+
case "list": {
|
|
892
|
+
if (params.status !== undefined && !isTodoStatus(params.status))
|
|
893
|
+
return error(state, "status is invalid");
|
|
894
|
+
if (params.includeDeleted !== undefined && typeof params.includeDeleted !== "boolean") {
|
|
895
|
+
return error(state, "includeDeleted must be a boolean");
|
|
896
|
+
}
|
|
897
|
+
if (params.unblockedOnly !== undefined && typeof params.unblockedOnly !== "boolean") {
|
|
898
|
+
return error(state, "unblockedOnly must be a boolean");
|
|
899
|
+
}
|
|
900
|
+
if (params.limit !== undefined &&
|
|
901
|
+
(!Number.isSafeInteger(params.limit) || params.limit < 1 || params.limit > TODO_MAX_LIST_LIMIT)) {
|
|
902
|
+
return error(state, `limit must be an integer from 1 to ${TODO_MAX_LIST_LIMIT}`);
|
|
903
|
+
}
|
|
904
|
+
if (params.afterId !== undefined && !isPositiveInteger(params.afterId))
|
|
905
|
+
return error(state, "afterId must be a positive integer");
|
|
906
|
+
if (params.query !== undefined) {
|
|
907
|
+
const queryError = validateBoundedString(params.query, "query", TODO_MAX_LIST_QUERY_LENGTH);
|
|
908
|
+
if (queryError)
|
|
909
|
+
return error(state, queryError);
|
|
910
|
+
}
|
|
425
911
|
return {
|
|
426
912
|
state,
|
|
427
913
|
operation: {
|
|
428
914
|
kind: "list",
|
|
429
915
|
status: params.status,
|
|
430
916
|
includeDeleted: params.includeDeleted === true,
|
|
917
|
+
limit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,
|
|
918
|
+
...(params.afterId !== undefined ? { afterId: params.afterId } : {}),
|
|
919
|
+
...(params.query !== undefined ? { query: params.query } : {}),
|
|
920
|
+
unblockedOnly: params.unblockedOnly === true,
|
|
921
|
+
legacyOutput: params.limit === undefined &&
|
|
922
|
+
params.afterId === undefined &&
|
|
923
|
+
params.query === undefined &&
|
|
924
|
+
params.unblockedOnly === undefined,
|
|
431
925
|
},
|
|
432
926
|
};
|
|
927
|
+
}
|
|
433
928
|
case "get": {
|
|
434
|
-
if (params.id
|
|
929
|
+
if (!isPositiveInteger(params.id))
|
|
435
930
|
return error(state, "id required for get");
|
|
436
931
|
const item = findItem(state, params.id);
|
|
437
932
|
if (!item)
|
|
@@ -439,7 +934,7 @@ export function applyTodoMutation(input, params) {
|
|
|
439
934
|
return { state, operation: { kind: "get", item } };
|
|
440
935
|
}
|
|
441
936
|
case "delete": {
|
|
442
|
-
if (params.id
|
|
937
|
+
if (!isPositiveInteger(params.id))
|
|
443
938
|
return error(state, "id required for delete");
|
|
444
939
|
const index = state.items.findIndex((item) => item.id === params.id);
|
|
445
940
|
if (index === -1)
|
|
@@ -459,6 +954,11 @@ export function applyTodoMutation(input, params) {
|
|
|
459
954
|
};
|
|
460
955
|
}
|
|
461
956
|
case "clear":
|
|
957
|
+
if (params.confirm !== true)
|
|
958
|
+
return error(state, "clear requires confirm: true");
|
|
959
|
+
if (!Number.isSafeInteger(params.expectedCount) || params.expectedCount !== state.items.length) {
|
|
960
|
+
return error(state, `clear expectedCount must equal current task count (${state.items.length})`);
|
|
961
|
+
}
|
|
462
962
|
return {
|
|
463
963
|
// IDs remain monotonic across a clear so stale references cannot point
|
|
464
964
|
// at an unrelated task created later on the same conversation branch.
|
|
@@ -466,38 +966,236 @@ export function applyTodoMutation(input, params) {
|
|
|
466
966
|
operation: { kind: "clear", count: state.items.length },
|
|
467
967
|
};
|
|
468
968
|
}
|
|
469
|
-
return error(state, `unknown action: ${params.action}`);
|
|
470
969
|
}
|
|
471
|
-
function
|
|
472
|
-
if (!value
|
|
970
|
+
function isTodoStatusCounts(value) {
|
|
971
|
+
if (!isPlainRecord(value))
|
|
473
972
|
return false;
|
|
474
|
-
const
|
|
475
|
-
|
|
973
|
+
for (const [status, count] of Object.entries(value)) {
|
|
974
|
+
if (!isTodoStatus(status) || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0)
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
function isOperationSummary(value, action) {
|
|
980
|
+
if (!isPlainRecord(value) || value.kind !== action)
|
|
981
|
+
return false;
|
|
982
|
+
switch (action) {
|
|
983
|
+
case "create":
|
|
984
|
+
case "create_many":
|
|
985
|
+
return Array.isArray(value.ids) && value.ids.every(isPositiveInteger);
|
|
986
|
+
case "update":
|
|
987
|
+
return isPositiveInteger(value.id) && isTodoStatus(value.status);
|
|
988
|
+
case "list":
|
|
989
|
+
return (typeof value.includeDeleted === "boolean" &&
|
|
990
|
+
typeof value.unblockedOnly === "boolean" &&
|
|
991
|
+
(value.status === undefined || isTodoStatus(value.status)) &&
|
|
992
|
+
(value.limit === undefined ||
|
|
993
|
+
(typeof value.limit === "number" && Number.isSafeInteger(value.limit) && value.limit >= 1)) &&
|
|
994
|
+
(value.afterId === undefined || isPositiveInteger(value.afterId)) &&
|
|
995
|
+
(value.query === undefined || typeof value.query === "string") &&
|
|
996
|
+
(value.statusCounts === undefined || isTodoStatusCounts(value.statusCounts)) &&
|
|
997
|
+
(value.resultCount === undefined ||
|
|
998
|
+
(typeof value.resultCount === "number" &&
|
|
999
|
+
Number.isSafeInteger(value.resultCount) &&
|
|
1000
|
+
value.resultCount >= 0)));
|
|
1001
|
+
case "get":
|
|
1002
|
+
case "delete":
|
|
1003
|
+
return isPositiveInteger(value.id);
|
|
1004
|
+
case "clear":
|
|
1005
|
+
return typeof value.count === "number" && Number.isSafeInteger(value.count) && value.count >= 0;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function normalizeSnapshotState(itemsValue, nextIdValue) {
|
|
1009
|
+
if (!Array.isArray(itemsValue) || !isPositiveInteger(nextIdValue))
|
|
1010
|
+
return undefined;
|
|
1011
|
+
const items = [];
|
|
1012
|
+
const ids = new Set();
|
|
1013
|
+
const itemsById = new Map();
|
|
1014
|
+
for (const rawItem of itemsValue) {
|
|
1015
|
+
if (!isPlainRecord(rawItem) || !isPositiveInteger(rawItem.id) || ids.has(rawItem.id))
|
|
1016
|
+
return undefined;
|
|
1017
|
+
if (typeof rawItem.subject !== "string" || !isTodoStatus(rawItem.status))
|
|
1018
|
+
return undefined;
|
|
1019
|
+
if (rawItem.description !== undefined && typeof rawItem.description !== "string")
|
|
1020
|
+
return undefined;
|
|
1021
|
+
if (rawItem.activeForm !== undefined && typeof rawItem.activeForm !== "string")
|
|
1022
|
+
return undefined;
|
|
1023
|
+
if (rawItem.owner !== undefined && typeof rawItem.owner !== "string")
|
|
1024
|
+
return undefined;
|
|
1025
|
+
let blockedBy;
|
|
1026
|
+
if (rawItem.blockedBy !== undefined) {
|
|
1027
|
+
if (!Array.isArray(rawItem.blockedBy) || !rawItem.blockedBy.every(isPositiveInteger))
|
|
1028
|
+
return undefined;
|
|
1029
|
+
blockedBy = Array.from(new Set(rawItem.blockedBy));
|
|
1030
|
+
}
|
|
1031
|
+
let metadata;
|
|
1032
|
+
if (rawItem.metadata !== undefined) {
|
|
1033
|
+
const metadataResult = validateAndCloneMetadata(rawItem.metadata, LEGACY_METADATA_LIMITS);
|
|
1034
|
+
if ("error" in metadataResult)
|
|
1035
|
+
return undefined;
|
|
1036
|
+
metadata = metadataResult.metadata;
|
|
1037
|
+
}
|
|
1038
|
+
const item = { id: rawItem.id, subject: rawItem.subject, status: rawItem.status };
|
|
1039
|
+
if (rawItem.description !== undefined)
|
|
1040
|
+
item.description = rawItem.description;
|
|
1041
|
+
if (rawItem.activeForm !== undefined)
|
|
1042
|
+
item.activeForm = rawItem.activeForm;
|
|
1043
|
+
if (blockedBy?.length)
|
|
1044
|
+
item.blockedBy = blockedBy;
|
|
1045
|
+
if (rawItem.owner !== undefined)
|
|
1046
|
+
item.owner = rawItem.owner;
|
|
1047
|
+
if (metadata)
|
|
1048
|
+
item.metadata = metadata;
|
|
1049
|
+
items.push(item);
|
|
1050
|
+
itemsById.set(item.id, item);
|
|
1051
|
+
ids.add(item.id);
|
|
1052
|
+
}
|
|
1053
|
+
const highestId = items.reduce((highest, item) => Math.max(highest, item.id), 0);
|
|
1054
|
+
if (nextIdValue <= highestId)
|
|
1055
|
+
return undefined;
|
|
1056
|
+
for (const item of items) {
|
|
1057
|
+
for (const dependencyId of item.blockedBy ?? []) {
|
|
1058
|
+
const dependency = itemsById.get(dependencyId);
|
|
1059
|
+
if (!dependency || dependency.status === "deleted")
|
|
1060
|
+
return undefined;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (createsCycle(items, new Map()))
|
|
1064
|
+
return undefined;
|
|
1065
|
+
// Legacy snapshots could contain the formerly permitted "active but newly
|
|
1066
|
+
// blocked" state. Normalize it to the current invariant rather than losing
|
|
1067
|
+
// the newest otherwise-valid task list during replay.
|
|
1068
|
+
for (const item of items) {
|
|
1069
|
+
if (item.status !== "in_progress")
|
|
1070
|
+
continue;
|
|
1071
|
+
if ((item.blockedBy ?? []).some((id) => itemsById.get(id)?.status !== "completed")) {
|
|
1072
|
+
item.status = "pending";
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (items.filter((item) => item.status === "in_progress").length > 1)
|
|
1076
|
+
return undefined;
|
|
1077
|
+
items.sort((first, second) => first.id - second.id);
|
|
1078
|
+
return { items, nextId: nextIdValue };
|
|
1079
|
+
}
|
|
1080
|
+
function normalizeDetails(value) {
|
|
1081
|
+
if (!isPlainRecord(value) || !isTodoAction(value.action) || !isPlainRecord(value.params))
|
|
1082
|
+
return undefined;
|
|
1083
|
+
if (value.schemaVersion !== undefined) {
|
|
1084
|
+
if (value.schemaVersion !== TODO_DETAILS_SCHEMA_VERSION || !isOperationSummary(value.operation, value.action)) {
|
|
1085
|
+
return undefined;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return normalizeSnapshotState(value.items, value.nextId);
|
|
476
1089
|
}
|
|
477
1090
|
export function replayTodosFromBranch(ctx) {
|
|
478
|
-
// Latest todo toolResult wins; scan tail → head so
|
|
1091
|
+
// Latest valid todo toolResult wins; scan tail → head so malformed latest
|
|
1092
|
+
// snapshots cannot poison a branch that still has an earlier valid state.
|
|
479
1093
|
const branch = Array.from(ctx.sessionManager.getBranch());
|
|
480
|
-
for (let
|
|
481
|
-
const
|
|
482
|
-
if (
|
|
1094
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
1095
|
+
const entry = branch[index];
|
|
1096
|
+
if (entry.type !== "message")
|
|
483
1097
|
continue;
|
|
484
|
-
if (
|
|
1098
|
+
if (entry.message?.role !== "toolResult" || entry.message.toolName !== TODO_TOOL_NAME)
|
|
485
1099
|
continue;
|
|
486
|
-
|
|
1100
|
+
let state;
|
|
1101
|
+
try {
|
|
1102
|
+
state = normalizeDetails(entry.message.details);
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
// Session history is external input; ignore hostile or malformed details.
|
|
487
1106
|
continue;
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
});
|
|
1107
|
+
}
|
|
1108
|
+
if (state)
|
|
1109
|
+
return cloneState(state);
|
|
492
1110
|
}
|
|
493
1111
|
return cloneState(EMPTY_TODO_STATE);
|
|
494
1112
|
}
|
|
1113
|
+
function cloneDetailsParams(params) {
|
|
1114
|
+
const copy = {};
|
|
1115
|
+
for (const [key, value] of Object.entries(params)) {
|
|
1116
|
+
if (value === undefined)
|
|
1117
|
+
continue;
|
|
1118
|
+
if (key === "metadata") {
|
|
1119
|
+
const metadata = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);
|
|
1120
|
+
if ("metadata" in metadata)
|
|
1121
|
+
copy[key] = metadata.metadata;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (key === "items" && Array.isArray(value)) {
|
|
1125
|
+
copy[key] = value.map((item) => {
|
|
1126
|
+
if (!isPlainRecord(item))
|
|
1127
|
+
return item;
|
|
1128
|
+
const itemCopy = { ...item };
|
|
1129
|
+
if (item.metadata !== undefined) {
|
|
1130
|
+
const metadata = validateAndCloneMetadata(item.metadata, LEGACY_METADATA_LIMITS);
|
|
1131
|
+
if ("metadata" in metadata)
|
|
1132
|
+
itemCopy.metadata = metadata.metadata;
|
|
1133
|
+
}
|
|
1134
|
+
return itemCopy;
|
|
1135
|
+
});
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
copy[key] = value;
|
|
1139
|
+
}
|
|
1140
|
+
return copy;
|
|
1141
|
+
}
|
|
1142
|
+
function buildTodoOperationSummary(params, next) {
|
|
1143
|
+
switch (params.action) {
|
|
1144
|
+
case "create":
|
|
1145
|
+
return { kind: "create", ids: [next.nextId - 1] };
|
|
1146
|
+
case "create_many": {
|
|
1147
|
+
const count = Array.isArray(params.items) ? params.items.length : 0;
|
|
1148
|
+
const firstId = next.nextId - count;
|
|
1149
|
+
return { kind: "create_many", ids: Array.from({ length: count }, (_, index) => firstId + index) };
|
|
1150
|
+
}
|
|
1151
|
+
case "update": {
|
|
1152
|
+
const item = findItem(next, params.id);
|
|
1153
|
+
return { kind: "update", id: params.id, status: item?.status ?? "pending" };
|
|
1154
|
+
}
|
|
1155
|
+
case "list": {
|
|
1156
|
+
const listOperation = {
|
|
1157
|
+
kind: "list",
|
|
1158
|
+
...(params.status !== undefined ? { status: params.status } : {}),
|
|
1159
|
+
includeDeleted: params.includeDeleted === true,
|
|
1160
|
+
limit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,
|
|
1161
|
+
...(params.afterId !== undefined ? { afterId: params.afterId } : {}),
|
|
1162
|
+
...(params.query !== undefined ? { query: params.query } : {}),
|
|
1163
|
+
unblockedOnly: params.unblockedOnly === true,
|
|
1164
|
+
legacyOutput: false,
|
|
1165
|
+
};
|
|
1166
|
+
const shown = getListView(listOperation, next).slice(0, listOperation.limit);
|
|
1167
|
+
const statusCounts = {};
|
|
1168
|
+
for (const item of shown)
|
|
1169
|
+
statusCounts[item.status] = (statusCounts[item.status] ?? 0) + 1;
|
|
1170
|
+
return {
|
|
1171
|
+
kind: "list",
|
|
1172
|
+
...(listOperation.status !== undefined ? { status: listOperation.status } : {}),
|
|
1173
|
+
includeDeleted: listOperation.includeDeleted,
|
|
1174
|
+
limit: listOperation.limit,
|
|
1175
|
+
...(listOperation.afterId !== undefined ? { afterId: listOperation.afterId } : {}),
|
|
1176
|
+
...(listOperation.query !== undefined ? { query: listOperation.query } : {}),
|
|
1177
|
+
unblockedOnly: listOperation.unblockedOnly,
|
|
1178
|
+
statusCounts,
|
|
1179
|
+
resultCount: shown.length,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
case "get":
|
|
1183
|
+
return { kind: "get", id: params.id };
|
|
1184
|
+
case "delete":
|
|
1185
|
+
return { kind: "delete", id: params.id };
|
|
1186
|
+
case "clear":
|
|
1187
|
+
return { kind: "clear", count: params.expectedCount };
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
495
1190
|
export function buildTodoDetails(params, next) {
|
|
1191
|
+
const snapshot = cloneState(next);
|
|
496
1192
|
return {
|
|
1193
|
+
schemaVersion: TODO_DETAILS_SCHEMA_VERSION,
|
|
497
1194
|
action: params.action,
|
|
498
|
-
params: params,
|
|
499
|
-
|
|
500
|
-
|
|
1195
|
+
params: cloneDetailsParams(params),
|
|
1196
|
+
operation: buildTodoOperationSummary(params, snapshot),
|
|
1197
|
+
items: snapshot.items,
|
|
1198
|
+
nextId: snapshot.nextId,
|
|
501
1199
|
};
|
|
502
1200
|
}
|
|
503
1201
|
const VERIFICATION_NUDGE_NOTE = "NOTE: You closed out 3+ tasks with no verification/test/review step. Before the final summary, run checks or add a verification task and complete it — do not self-declare done with known failures.";
|
|
@@ -506,11 +1204,46 @@ function formatReleasedSuffix(releasedIds) {
|
|
|
506
1204
|
return "";
|
|
507
1205
|
return `; released ${releasedIds.map((id) => `#${id}`).join(",")} (no longer blocked)`;
|
|
508
1206
|
}
|
|
1207
|
+
function formatDisplayText(value, maximum) {
|
|
1208
|
+
return value.length > maximum ? `${value.slice(0, maximum)}…` : value;
|
|
1209
|
+
}
|
|
1210
|
+
function getListView(operation, state) {
|
|
1211
|
+
let view = state.items;
|
|
1212
|
+
// An explicit deleted-status query is sufficient intent to include tombstones.
|
|
1213
|
+
if (!operation.includeDeleted && operation.status !== "deleted") {
|
|
1214
|
+
view = view.filter((item) => item.status !== "deleted");
|
|
1215
|
+
}
|
|
1216
|
+
if (operation.status)
|
|
1217
|
+
view = view.filter((item) => item.status === operation.status);
|
|
1218
|
+
if (operation.afterId !== undefined) {
|
|
1219
|
+
const afterId = operation.afterId;
|
|
1220
|
+
view = view.filter((item) => item.id > afterId);
|
|
1221
|
+
}
|
|
1222
|
+
if (operation.query !== undefined) {
|
|
1223
|
+
const query = operation.query.toLocaleLowerCase();
|
|
1224
|
+
view = view.filter((item) => item.subject.toLocaleLowerCase().includes(query) || item.description?.toLocaleLowerCase().includes(query));
|
|
1225
|
+
}
|
|
1226
|
+
if (operation.unblockedOnly)
|
|
1227
|
+
view = view.filter((item) => dependenciesSatisfied(state, item));
|
|
1228
|
+
return [...view].sort((first, second) => first.id - second.id);
|
|
1229
|
+
}
|
|
509
1230
|
export function formatTodoContent(operation, state) {
|
|
510
1231
|
switch (operation.kind) {
|
|
511
1232
|
case "create": {
|
|
512
1233
|
const item = findItem(state, operation.id);
|
|
513
|
-
return item
|
|
1234
|
+
return item
|
|
1235
|
+
? `Created #${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (pending)`
|
|
1236
|
+
: `Created #${operation.id}`;
|
|
1237
|
+
}
|
|
1238
|
+
case "create_many": {
|
|
1239
|
+
const noun = operation.ids.length === 1 ? "task" : "tasks";
|
|
1240
|
+
const lines = operation.ids.map((id) => {
|
|
1241
|
+
const item = findItem(state, id);
|
|
1242
|
+
return item
|
|
1243
|
+
? `#${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (${item.status})`
|
|
1244
|
+
: `#${id}`;
|
|
1245
|
+
});
|
|
1246
|
+
return `Created ${operation.ids.length} ${noun}:${lines.length ? `\n${lines.join("\n")}` : ""}`;
|
|
514
1247
|
}
|
|
515
1248
|
case "update": {
|
|
516
1249
|
const transition = operation.from === operation.to ? "" : ` (${operation.from} -> ${operation.to})`;
|
|
@@ -518,24 +1251,20 @@ export function formatTodoContent(operation, state) {
|
|
|
518
1251
|
if (operation.demotedIds?.length) {
|
|
519
1252
|
text += `; ${operation.demotedIds.map((id) => `#${id}`).join(",")} demoted to pending`;
|
|
520
1253
|
}
|
|
1254
|
+
if (operation.blockedIds?.length) {
|
|
1255
|
+
text += `; ${operation.blockedIds.map((id) => `#${id}`).join(",")} moved to pending (dependencies incomplete)`;
|
|
1256
|
+
}
|
|
521
1257
|
text += formatReleasedSuffix(operation.releasedIds);
|
|
522
1258
|
if (operation.verificationNudge)
|
|
523
1259
|
text += `\n\n${VERIFICATION_NUDGE_NOTE}`;
|
|
524
1260
|
return text;
|
|
525
1261
|
}
|
|
526
1262
|
case "delete":
|
|
527
|
-
return `Deleted #${operation.id}: ${operation.subject}${formatReleasedSuffix(operation.releasedIds)}`;
|
|
1263
|
+
return `Deleted #${operation.id}: ${formatDisplayText(operation.subject, TODO_MAX_SUBJECT_LENGTH)}${formatReleasedSuffix(operation.releasedIds)}`;
|
|
528
1264
|
case "clear":
|
|
529
1265
|
return `Cleared ${operation.count} tasks`;
|
|
530
|
-
case "list":
|
|
531
|
-
|
|
532
|
-
// An explicit deleted-status query is sufficient intent to include tombstones.
|
|
533
|
-
if (!operation.includeDeleted && operation.status !== "deleted")
|
|
534
|
-
view = view.filter((item) => item.status !== "deleted");
|
|
535
|
-
if (operation.status)
|
|
536
|
-
view = view.filter((item) => item.status === operation.status);
|
|
537
|
-
return formatBoundedList(view, state, operation.status);
|
|
538
|
-
}
|
|
1266
|
+
case "list":
|
|
1267
|
+
return formatBoundedList(getListView(operation, state), state, operation);
|
|
539
1268
|
case "get":
|
|
540
1269
|
return formatDetailItem(operation.item, state);
|
|
541
1270
|
case "error":
|
|
@@ -558,41 +1287,56 @@ function needsVerificationNudge(state) {
|
|
|
558
1287
|
return !completed.some((item) => VERIFICATION_PATTERN.test(item.subject) ||
|
|
559
1288
|
(item.description !== undefined && VERIFICATION_PATTERN.test(item.description)));
|
|
560
1289
|
}
|
|
561
|
-
function formatBoundedList(view, state,
|
|
1290
|
+
function formatBoundedList(view, state, operation) {
|
|
562
1291
|
if (!view.length)
|
|
563
1292
|
return "No tasks";
|
|
564
|
-
const shown = view.slice(0,
|
|
1293
|
+
const shown = view.slice(0, operation.limit);
|
|
565
1294
|
const lines = shown.map((item) => formatListItem(item, state));
|
|
566
1295
|
const omitted = view.length - shown.length;
|
|
567
1296
|
if (omitted > 0) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1297
|
+
if (!operation.legacyOutput) {
|
|
1298
|
+
lines.push(`… and ${omitted} more; next page: afterId=${shown.at(-1)?.id}.`);
|
|
1299
|
+
}
|
|
1300
|
+
else {
|
|
1301
|
+
// Do not suggest a filter that is already applied.
|
|
1302
|
+
const hint = operation.status ? "use get with id= for details" : "narrow with status= or use get with id=";
|
|
1303
|
+
lines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);
|
|
1304
|
+
}
|
|
571
1305
|
}
|
|
572
1306
|
return lines.join("\n");
|
|
573
1307
|
}
|
|
1308
|
+
function formatListIdList(ids) {
|
|
1309
|
+
const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
|
|
1310
|
+
return ids.length > shown.length ? `${shown.join(",")},…+${ids.length - shown.length}` : shown.join(",");
|
|
1311
|
+
}
|
|
574
1312
|
function formatListItem(item, state) {
|
|
575
1313
|
const unresolved = unresolvedDependencyIds(state, item);
|
|
576
|
-
const deps = unresolved.length ? ` blockedBy=${unresolved
|
|
577
|
-
const owner = item.owner ? ` @${item.owner}` : "";
|
|
578
|
-
const active = item.status === "in_progress" && item.activeForm
|
|
579
|
-
|
|
1314
|
+
const deps = unresolved.length ? ` blockedBy=${formatListIdList(unresolved)}` : "";
|
|
1315
|
+
const owner = item.owner ? ` @${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}` : "";
|
|
1316
|
+
const active = item.status === "in_progress" && item.activeForm
|
|
1317
|
+
? ` (${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)})`
|
|
1318
|
+
: "";
|
|
1319
|
+
return `[${item.status}] #${item.id} ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}${active}${owner}${deps}`;
|
|
1320
|
+
}
|
|
1321
|
+
function formatIdList(ids) {
|
|
1322
|
+
const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
|
|
1323
|
+
return ids.length > shown.length ? `${shown.join(", ")}, … +${ids.length - shown.length}` : shown.join(", ");
|
|
580
1324
|
}
|
|
581
1325
|
function formatDetailItem(item, state) {
|
|
582
|
-
const lines = [`#${item.id} [${item.status}] ${item.subject}`];
|
|
1326
|
+
const lines = [`#${item.id} [${item.status}] ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}`];
|
|
583
1327
|
if (item.description)
|
|
584
|
-
lines.push(`description: ${item.description}`);
|
|
1328
|
+
lines.push(`description: ${formatDisplayText(item.description, TODO_MAX_DESCRIPTION_LENGTH)}`);
|
|
585
1329
|
if (item.activeForm)
|
|
586
|
-
lines.push(`activeForm: ${item.activeForm}`);
|
|
1330
|
+
lines.push(`activeForm: ${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)}`);
|
|
587
1331
|
if (item.blockedBy?.length)
|
|
588
|
-
lines.push(`blockedBy: ${item.blockedBy
|
|
1332
|
+
lines.push(`blockedBy: ${formatIdList(item.blockedBy)}`);
|
|
589
1333
|
const blocks = state.items
|
|
590
1334
|
.filter((candidate) => candidate.blockedBy?.includes(item.id))
|
|
591
1335
|
.map((candidate) => candidate.id);
|
|
592
1336
|
if (blocks.length)
|
|
593
|
-
lines.push(`blocks: ${blocks
|
|
1337
|
+
lines.push(`blocks: ${formatIdList(blocks)}`);
|
|
594
1338
|
if (item.owner)
|
|
595
|
-
lines.push(`owner: ${item.owner}`);
|
|
1339
|
+
lines.push(`owner: ${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}`);
|
|
596
1340
|
return lines.join("\n");
|
|
597
1341
|
}
|
|
598
1342
|
//# sourceMappingURL=state.js.map
|