@astralyn/pi 0.87.2 → 0.87.3
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 +10 -0
- package/dist/bundle/chunks/{chunk-NBIUJDV6.js → chunk-MJ45I4SM.js} +1 -1
- package/dist/bundle/chunks/{chunk-RFLBIA5Z.js → chunk-TYOQD6VQ.js} +33 -20
- package/dist/bundle/chunks/{virtual-modules-GOHLE5JL.js → virtual-modules-NDGCHNG7.js} +1 -1
- package/dist/bundle/cli.js +1 -1
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
- package/dist/core/tools/tool-definition-wrapper.js +114 -1
- package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
- package/dist/extensions/background/actions.d.ts.map +1 -1
- package/dist/extensions/background/actions.js +107 -55
- package/dist/extensions/background/actions.js.map +1 -1
- package/dist/extensions/background/index.js +3 -3
- package/dist/extensions/background/index.js.map +1 -1
- package/dist/extensions/question/index.d.ts.map +1 -1
- package/dist/extensions/question/index.js +3 -1
- package/dist/extensions/question/index.js.map +1 -1
- package/dist/extensions/question/schema.d.ts +18 -0
- package/dist/extensions/question/schema.d.ts.map +1 -1
- package/dist/extensions/question/schema.js +52 -0
- package/dist/extensions/question/schema.js.map +1 -1
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +3 -1
- package/dist/extensions/subagent/index.js.map +1 -1
- package/dist/extensions/subagent/resolve.d.ts +14 -1
- package/dist/extensions/subagent/resolve.d.ts.map +1 -1
- package/dist/extensions/subagent/resolve.js +32 -18
- package/dist/extensions/subagent/resolve.js.map +1 -1
- package/dist/extensions/subagent/runner.d.ts.map +1 -1
- package/dist/extensions/subagent/runner.js +4 -1
- package/dist/extensions/subagent/runner.js.map +1 -1
- package/dist/extensions/subagent/schema.d.ts +9 -0
- package/dist/extensions/subagent/schema.d.ts.map +1 -1
- package/dist/extensions/subagent/schema.js +30 -1
- package/dist/extensions/subagent/schema.js.map +1 -1
- package/dist/extensions/subagent/types.d.ts +2 -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 +1 -1
- package/dist/extensions/todo/constants.d.ts.map +1 -1
- package/dist/extensions/todo/constants.js +8 -8
- package/dist/extensions/todo/constants.js.map +1 -1
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +1 -0
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +30 -25
- package/dist/extensions/todo/schema.d.ts.map +1 -1
- package/dist/extensions/todo/schema.js +26 -20
- package/dist/extensions/todo/schema.js.map +1 -1
- package/dist/extensions/todo/state.d.ts +12 -8
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +255 -171
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts +6 -2
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +234 -144
- package/dist/extensions/todo/view.js.map +1 -1
- package/docs/bundled/extensions/background.md +1 -1
- package/docs/bundled/extensions/question.md +1 -0
- package/docs/bundled/extensions/subagent.md +3 -1
- package/docs/bundled/extensions/todo.md +35 -23
- package/docs/extensions.md +2 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* todo/state.ts —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* todo/state.ts — v3 pure state core: closure-scoped store, atomic patch
|
|
3
|
+
* application (delete, then update, then create), snapshot cloning, and
|
|
4
|
+
* branch replay. No session registry, metadata, dependency graph, tombstones,
|
|
5
|
+
* filters, or legacy normalizers.
|
|
6
|
+
*
|
|
7
|
+
* The patch shape is designed so every value a strict sampler may fill in for
|
|
8
|
+
* an unused field ([], "", null already stripped by validation) is a no-op:
|
|
9
|
+
* empty groups do nothing, blank update text keeps the current value, and
|
|
10
|
+
* deleting an absent id is recorded rather than rejected.
|
|
5
11
|
*/
|
|
6
12
|
import { TODO_MAX_BATCH_ITEMS, TODO_MAX_DESCRIPTION_LENGTH, TODO_MAX_ITEMS, TODO_MAX_SUBJECT_LENGTH, TODO_TOOL_NAME, } from "./constants.js";
|
|
7
13
|
import { EMPTY_TODO_STATE, TODO_DETAILS_SCHEMA_VERSION, } from "./schema.js";
|
|
8
14
|
const TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
|
|
9
|
-
const
|
|
10
|
-
/** Parameters each action accepts; anything else is rejected with guidance. */
|
|
11
|
-
const ACTION_PARAMS = {
|
|
12
|
-
create: new Set(["items"]),
|
|
13
|
-
update: new Set(["id", "subject", "description", "status"]),
|
|
14
|
-
list: new Set([]),
|
|
15
|
-
delete: new Set(["ids"]),
|
|
16
|
-
};
|
|
15
|
+
const GROUP_FIELDS = new Set(["create", "update", "delete"]);
|
|
17
16
|
function isRecord(value) {
|
|
18
17
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
18
|
}
|
|
@@ -23,12 +22,6 @@ function isPositiveSafeInteger(value) {
|
|
|
23
22
|
function isTodoStatus(value) {
|
|
24
23
|
return typeof value === "string" && TODO_STATUSES.has(value);
|
|
25
24
|
}
|
|
26
|
-
function isTodoAction(value) {
|
|
27
|
-
return typeof value === "string" && TODO_ACTIONS.has(value);
|
|
28
|
-
}
|
|
29
|
-
function findItem(state, id) {
|
|
30
|
-
return state.items.find((item) => item.id === id);
|
|
31
|
-
}
|
|
32
25
|
/** De-duplicate ids preserving first-seen input order. */
|
|
33
26
|
function dedupeIds(ids) {
|
|
34
27
|
const seen = [];
|
|
@@ -42,7 +35,8 @@ function dedupeIds(ids) {
|
|
|
42
35
|
function normalizeText(value) {
|
|
43
36
|
return value.trim().replace(/\s+/g, " ");
|
|
44
37
|
}
|
|
45
|
-
|
|
38
|
+
/** Required text (create): normalized, non-empty, within the limit. */
|
|
39
|
+
function validateRequiredText(value, label, maximum) {
|
|
46
40
|
if (typeof value !== "string")
|
|
47
41
|
throw new Error(`${label} must be a string`);
|
|
48
42
|
const normalized = normalizeText(value);
|
|
@@ -52,170 +46,260 @@ function validateNormalizedText(value, label, maximum) {
|
|
|
52
46
|
throw new Error(`${label} exceeds ${maximum} characters`);
|
|
53
47
|
return normalized;
|
|
54
48
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Optional text (update): normalized and within the limit, where blank means
|
|
51
|
+
* "keep the current value" (undefined) so strict-mode filler stays harmless.
|
|
52
|
+
*/
|
|
53
|
+
function validateOptionalText(value, label, maximum) {
|
|
54
|
+
if (value === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (typeof value !== "string")
|
|
57
|
+
throw new Error(`${label} must be a string`);
|
|
58
|
+
const normalized = normalizeText(value);
|
|
59
|
+
if (!normalized)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (normalized.length > maximum)
|
|
62
|
+
throw new Error(`${label} exceeds ${maximum} characters`);
|
|
63
|
+
return normalized;
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
function validateOptionalStatus(value, label) {
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
if (!isTodoStatus(value))
|
|
69
|
+
throw new Error(`${label} is invalid`);
|
|
70
|
+
return value;
|
|
68
71
|
}
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const raw = rawItems[index];
|
|
85
|
-
if (!isRecord(raw))
|
|
86
|
-
throw new Error(`items[${index}] must be an object`);
|
|
87
|
-
for (const key of Object.keys(raw)) {
|
|
88
|
-
if (key !== "subject" && key !== "description") {
|
|
89
|
-
throw new Error(`items[${index}].${key} does not apply to action create`);
|
|
72
|
+
function readCreateEntries(raw) {
|
|
73
|
+
if (raw === undefined)
|
|
74
|
+
return [];
|
|
75
|
+
if (!Array.isArray(raw))
|
|
76
|
+
throw new Error("create must be an array");
|
|
77
|
+
if (raw.length > TODO_MAX_BATCH_ITEMS)
|
|
78
|
+
throw new Error(`create exceeds ${TODO_MAX_BATCH_ITEMS} tasks`);
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (let index = 0; index < raw.length; index++) {
|
|
81
|
+
const item = raw[index];
|
|
82
|
+
if (!isRecord(item))
|
|
83
|
+
throw new Error(`create[${index}] must be an object`);
|
|
84
|
+
for (const key of Object.keys(item)) {
|
|
85
|
+
if (key !== "subject" && key !== "description" && key !== "status") {
|
|
86
|
+
throw new Error(`create[${index}].${key} is not a create field; valid fields: subject, description, status`);
|
|
90
87
|
}
|
|
91
88
|
}
|
|
92
|
-
|
|
93
|
-
subject:
|
|
94
|
-
description:
|
|
95
|
-
}
|
|
89
|
+
const entry = {
|
|
90
|
+
subject: validateRequiredText(item.subject, `create[${index}].subject`, TODO_MAX_SUBJECT_LENGTH),
|
|
91
|
+
description: validateRequiredText(item.description, `create[${index}].description`, TODO_MAX_DESCRIPTION_LENGTH),
|
|
92
|
+
};
|
|
93
|
+
const status = validateOptionalStatus(item.status, `create[${index}].status`);
|
|
94
|
+
if (status !== undefined)
|
|
95
|
+
entry.status = status;
|
|
96
|
+
entries.push(entry);
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
-
const created = sources.map((source, index) => ({
|
|
99
|
-
id: state.nextId + index,
|
|
100
|
-
subject: source.subject,
|
|
101
|
-
description: source.description,
|
|
102
|
-
status: "pending",
|
|
103
|
-
}));
|
|
104
|
-
return { items: [...state.items, ...created], nextId: state.nextId + created.length };
|
|
98
|
+
return entries;
|
|
105
99
|
}
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
throw new Error(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (params.status !== undefined && !isTodoStatus(params.status))
|
|
123
|
-
throw new Error("status is invalid");
|
|
124
|
-
const current = state.items[index];
|
|
125
|
-
const updated = {
|
|
126
|
-
id: current.id,
|
|
127
|
-
subject: subject ?? current.subject,
|
|
128
|
-
description: description ?? current.description,
|
|
129
|
-
status: params.status ?? current.status,
|
|
130
|
-
};
|
|
131
|
-
// Exactly one in_progress: setting one demotes every other active task.
|
|
132
|
-
const items = state.items.map((item, itemIndex) => {
|
|
133
|
-
if (itemIndex === index)
|
|
134
|
-
return updated;
|
|
135
|
-
if (updated.status === "in_progress" && item.status === "in_progress") {
|
|
136
|
-
return { ...item, status: "pending" };
|
|
100
|
+
function readUpdateEntries(raw) {
|
|
101
|
+
if (raw === undefined)
|
|
102
|
+
return [];
|
|
103
|
+
if (!Array.isArray(raw))
|
|
104
|
+
throw new Error("update must be an array");
|
|
105
|
+
if (raw.length > TODO_MAX_BATCH_ITEMS)
|
|
106
|
+
throw new Error(`update exceeds ${TODO_MAX_BATCH_ITEMS} tasks`);
|
|
107
|
+
const entries = [];
|
|
108
|
+
for (let index = 0; index < raw.length; index++) {
|
|
109
|
+
const item = raw[index];
|
|
110
|
+
if (!isRecord(item))
|
|
111
|
+
throw new Error(`update[${index}] must be an object`);
|
|
112
|
+
for (const key of Object.keys(item)) {
|
|
113
|
+
if (key !== "id" && key !== "subject" && key !== "description" && key !== "status") {
|
|
114
|
+
throw new Error(`update[${index}].${key} is not an update field; valid fields: id, subject, description, status`);
|
|
115
|
+
}
|
|
137
116
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
117
|
+
if (!isPositiveSafeInteger(item.id))
|
|
118
|
+
throw new Error(`update[${index}].id must be a positive integer`);
|
|
119
|
+
const entry = { id: item.id };
|
|
120
|
+
const subject = validateOptionalText(item.subject, `update[${index}].subject`, TODO_MAX_SUBJECT_LENGTH);
|
|
121
|
+
if (subject !== undefined)
|
|
122
|
+
entry.subject = subject;
|
|
123
|
+
const description = validateOptionalText(item.description, `update[${index}].description`, TODO_MAX_DESCRIPTION_LENGTH);
|
|
124
|
+
if (description !== undefined)
|
|
125
|
+
entry.description = description;
|
|
126
|
+
const status = validateOptionalStatus(item.status, `update[${index}].status`);
|
|
127
|
+
if (status !== undefined)
|
|
128
|
+
entry.status = status;
|
|
129
|
+
entries.push(entry);
|
|
130
|
+
}
|
|
131
|
+
return entries;
|
|
141
132
|
}
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
133
|
+
function readDeleteIds(raw) {
|
|
134
|
+
if (raw === undefined)
|
|
135
|
+
return [];
|
|
136
|
+
if (!Array.isArray(raw))
|
|
137
|
+
throw new Error("delete must be an array");
|
|
138
|
+
if (raw.length > TODO_MAX_BATCH_ITEMS)
|
|
139
|
+
throw new Error(`delete exceeds ${TODO_MAX_BATCH_ITEMS} ids`);
|
|
148
140
|
const ids = [];
|
|
149
|
-
for (
|
|
150
|
-
if (!isPositiveSafeInteger(raw))
|
|
151
|
-
throw new Error(
|
|
152
|
-
ids.push(raw);
|
|
141
|
+
for (let index = 0; index < raw.length; index++) {
|
|
142
|
+
if (!isPositiveSafeInteger(raw[index]))
|
|
143
|
+
throw new Error(`delete[${index}] must be a positive integer id`);
|
|
144
|
+
ids.push(raw[index]);
|
|
153
145
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
146
|
+
return ids;
|
|
147
|
+
}
|
|
148
|
+
function currentIdsLabel(state) {
|
|
149
|
+
if (state.items.length === 0)
|
|
150
|
+
return "the list is empty";
|
|
151
|
+
return `current ids: ${state.items.map((item) => `#${item.id}`).join(", ")}`;
|
|
152
|
+
}
|
|
153
|
+
/** Snapshot clone: items are plain string/number records, so a shallow copy is safe. */
|
|
154
|
+
export function cloneTodoState(state) {
|
|
155
|
+
return { items: state.items.map((item) => ({ ...item })), nextId: state.nextId };
|
|
163
156
|
}
|
|
164
157
|
/**
|
|
165
|
-
* Pure
|
|
166
|
-
*
|
|
167
|
-
*
|
|
158
|
+
* Pure patch application: validates every runtime input (tool args can be
|
|
159
|
+
* tampered after schema validation), throws on any invalid call, and never
|
|
160
|
+
* mutates the input state. Application order is delete, then update, then
|
|
161
|
+
* create, so a delete in the same call frees capacity for the create.
|
|
168
162
|
*/
|
|
169
|
-
export function
|
|
163
|
+
export function applyTodoPatch(before, params) {
|
|
170
164
|
if (!isRecord(params))
|
|
171
165
|
throw new Error("todo params must be an object");
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (inapplicable)
|
|
176
|
-
throw new Error(inapplicable);
|
|
177
|
-
switch (params.action) {
|
|
178
|
-
case "create":
|
|
179
|
-
return applyCreate(state, params);
|
|
180
|
-
case "update":
|
|
181
|
-
return applyUpdate(state, params);
|
|
182
|
-
case "list":
|
|
183
|
-
return cloneTodoState(state);
|
|
184
|
-
case "delete":
|
|
185
|
-
return applyDelete(state, params);
|
|
166
|
+
for (const key of Object.keys(params)) {
|
|
167
|
+
if (!GROUP_FIELDS.has(key))
|
|
168
|
+
throw new Error(`unknown todo field "${key}"; valid fields: create, update, delete`);
|
|
186
169
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
170
|
+
// Everything above the apply phase is validation: any throw leaves the
|
|
171
|
+
// caller's state untouched.
|
|
172
|
+
const createEntries = readCreateEntries(params.create);
|
|
173
|
+
const updateEntries = readUpdateEntries(params.update);
|
|
174
|
+
const deleteIds = readDeleteIds(params.delete);
|
|
175
|
+
// Conflicts: one edit per task per call, and a task cannot be both edited
|
|
176
|
+
// and removed in the same call.
|
|
177
|
+
const updatedIds = new Set();
|
|
178
|
+
for (const entry of updateEntries) {
|
|
179
|
+
if (updatedIds.has(entry.id))
|
|
180
|
+
throw new Error(`#${entry.id} appears more than once in update`);
|
|
181
|
+
updatedIds.add(entry.id);
|
|
182
|
+
}
|
|
183
|
+
const deleteSet = new Set(deleteIds);
|
|
184
|
+
for (const entry of updateEntries) {
|
|
185
|
+
if (deleteSet.has(entry.id))
|
|
186
|
+
throw new Error(`#${entry.id} cannot be in both update and delete`);
|
|
187
|
+
}
|
|
188
|
+
// Exactly one activation per call: more than one is a real contradiction,
|
|
189
|
+
// since a single task can be in_progress at any time.
|
|
190
|
+
const activations = [];
|
|
191
|
+
createEntries.forEach((entry, index) => {
|
|
192
|
+
if (entry.status === "in_progress")
|
|
193
|
+
activations.push(`create[${index}]`);
|
|
194
|
+
});
|
|
195
|
+
for (const entry of updateEntries) {
|
|
196
|
+
if (entry.status === "in_progress")
|
|
197
|
+
activations.push(`#${entry.id}`);
|
|
198
|
+
}
|
|
199
|
+
if (activations.length > 1) {
|
|
200
|
+
throw new Error(`at most one task may be set in_progress per call; got ${activations.join(" and ")}`);
|
|
201
|
+
}
|
|
202
|
+
const byId = new Map(before.items.map((item) => [item.id, item]));
|
|
203
|
+
for (const entry of updateEntries) {
|
|
204
|
+
if (!byId.has(entry.id))
|
|
205
|
+
throw new Error(`#${entry.id} not found; ${currentIdsLabel(before)}`);
|
|
206
|
+
}
|
|
207
|
+
if (createEntries.length > 0) {
|
|
208
|
+
if (!Number.isSafeInteger(before.nextId) || before.nextId < 1)
|
|
209
|
+
throw new Error("next id is invalid");
|
|
210
|
+
if (before.nextId > Number.MAX_SAFE_INTEGER - createEntries.length)
|
|
211
|
+
throw new Error("next id is exhausted");
|
|
212
|
+
}
|
|
213
|
+
// --- Apply: delete -------------------------------------------------------
|
|
214
|
+
const deleted = [];
|
|
215
|
+
const absent = [];
|
|
216
|
+
for (const id of dedupeIds(deleteIds)) {
|
|
217
|
+
const item = byId.get(id);
|
|
218
|
+
if (item)
|
|
219
|
+
deleted.push({ id: item.id, subject: item.subject });
|
|
220
|
+
else
|
|
221
|
+
absent.push(id);
|
|
222
|
+
}
|
|
223
|
+
let items = before.items.filter((item) => !deleteSet.has(item.id));
|
|
224
|
+
// --- Apply: update -------------------------------------------------------
|
|
225
|
+
const updated = [];
|
|
226
|
+
if (updateEntries.length > 0) {
|
|
227
|
+
const entriesById = new Map(updateEntries.map((entry) => [entry.id, entry]));
|
|
228
|
+
items = items.map((item) => {
|
|
229
|
+
const entry = entriesById.get(item.id);
|
|
230
|
+
if (!entry)
|
|
231
|
+
return item;
|
|
232
|
+
const next = {
|
|
233
|
+
id: item.id,
|
|
234
|
+
subject: entry.subject ?? item.subject,
|
|
235
|
+
description: entry.description ?? item.description,
|
|
236
|
+
status: entry.status ?? item.status,
|
|
237
|
+
};
|
|
238
|
+
updated.push({ id: item.id, from: item.status, to: next.status });
|
|
239
|
+
return next;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// --- Apply: create -------------------------------------------------------
|
|
243
|
+
const created = [];
|
|
244
|
+
if (createEntries.length > 0) {
|
|
245
|
+
const newItems = createEntries.map((entry, index) => {
|
|
246
|
+
const item = {
|
|
247
|
+
id: before.nextId + index,
|
|
248
|
+
subject: entry.subject,
|
|
249
|
+
description: entry.description,
|
|
250
|
+
status: entry.status ?? "pending",
|
|
206
251
|
};
|
|
252
|
+
return item;
|
|
253
|
+
});
|
|
254
|
+
for (const item of newItems)
|
|
255
|
+
created.push(item.id);
|
|
256
|
+
items = [...items, ...newItems];
|
|
257
|
+
}
|
|
258
|
+
// --- Single in_progress: the one task this call activates demotes the rest.
|
|
259
|
+
let demotedId;
|
|
260
|
+
let activatedId;
|
|
261
|
+
for (const entry of updateEntries) {
|
|
262
|
+
if (entry.status === "in_progress")
|
|
263
|
+
activatedId = entry.id;
|
|
264
|
+
}
|
|
265
|
+
if (activatedId === undefined) {
|
|
266
|
+
createEntries.forEach((entry, index) => {
|
|
267
|
+
if (entry.status === "in_progress")
|
|
268
|
+
activatedId = before.nextId + index;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (activatedId !== undefined) {
|
|
272
|
+
const demoted = items.find((item) => item.id !== activatedId && item.status === "in_progress");
|
|
273
|
+
if (demoted) {
|
|
274
|
+
demotedId = demoted.id;
|
|
275
|
+
items = items.map((item) => (item.id === demoted.id ? { ...item, status: "pending" } : item));
|
|
207
276
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
277
|
+
}
|
|
278
|
+
// --- Capacity: completed tasks are reclaimed oldest-first before a call
|
|
279
|
+
// may fail for space; only a list with more than the cap in open tasks is
|
|
280
|
+
// rejected.
|
|
281
|
+
const evicted = [];
|
|
282
|
+
if (items.length > TODO_MAX_ITEMS) {
|
|
283
|
+
let overflow = items.length - TODO_MAX_ITEMS;
|
|
284
|
+
const evictIds = new Set();
|
|
285
|
+
const completedOldestFirst = items
|
|
286
|
+
.filter((item) => item.status === "completed")
|
|
287
|
+
.sort((first, second) => first.id - second.id);
|
|
288
|
+
for (const item of completedOldestFirst) {
|
|
289
|
+
if (overflow <= 0)
|
|
290
|
+
break;
|
|
291
|
+
evictIds.add(item.id);
|
|
292
|
+
evicted.push({ id: item.id, subject: item.subject });
|
|
293
|
+
overflow--;
|
|
217
294
|
}
|
|
295
|
+
if (overflow > 0)
|
|
296
|
+
throw new Error(`at most ${TODO_MAX_ITEMS} open tasks; complete or delete some first`);
|
|
297
|
+
items = items.filter((item) => !evictIds.has(item.id));
|
|
218
298
|
}
|
|
299
|
+
const change = { created, updated, deleted, absent, evicted };
|
|
300
|
+
if (demotedId !== undefined)
|
|
301
|
+
change.demotedId = demotedId;
|
|
302
|
+
return { state: { items, nextId: before.nextId + created.length }, change };
|
|
219
303
|
}
|
|
220
304
|
export function buildTodoDetails(change, state) {
|
|
221
305
|
return { schemaVersion: TODO_DETAILS_SCHEMA_VERSION, change, state: cloneTodoState(state) };
|
|
@@ -237,19 +321,18 @@ export function createTodoStore(initial) {
|
|
|
237
321
|
state = requireValidState(next);
|
|
238
322
|
},
|
|
239
323
|
execute(params) {
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
state = after;
|
|
324
|
+
const patch = applyTodoPatch(state, params);
|
|
325
|
+
const details = buildTodoDetails(patch.change, patch.state);
|
|
326
|
+
state = patch.state;
|
|
244
327
|
return details;
|
|
245
328
|
},
|
|
246
329
|
};
|
|
247
330
|
}
|
|
248
331
|
/**
|
|
249
|
-
* Validate an external
|
|
332
|
+
* Validate an external v3 snapshot; returns undefined when malformed.
|
|
250
333
|
*
|
|
251
334
|
* Snapshot text must already be in normalizeText() form, which makes that
|
|
252
|
-
* function's output part of the
|
|
335
|
+
* function's output part of the v3 persistence contract: relaxing or changing
|
|
253
336
|
* it would silently invalidate every historical snapshot, falling back to an
|
|
254
337
|
* earlier one or to the empty state with no diagnostic. Change normalizeText
|
|
255
338
|
* only together with TODO_DETAILS_SCHEMA_VERSION.
|
|
@@ -296,9 +379,10 @@ function normalizeSnapshotState(value) {
|
|
|
296
379
|
return { items, nextId: value.nextId };
|
|
297
380
|
}
|
|
298
381
|
/**
|
|
299
|
-
* Replay the newest valid
|
|
382
|
+
* Replay the newest valid v3 todo snapshot from a branch, scanning tail to
|
|
300
383
|
* head so a malformed latest snapshot falls back to an earlier valid one.
|
|
301
|
-
* v1
|
|
384
|
+
* Older schema versions (v1/v2) are ignored: restoring a session written
|
|
385
|
+
* before v3 starts from an empty list.
|
|
302
386
|
*/
|
|
303
387
|
export function replayTodosFromBranch(ctx) {
|
|
304
388
|
const branch = Array.from(ctx.sessionManager.getBranch());
|
|
@@ -321,7 +405,7 @@ export function replayTodosFromBranch(ctx) {
|
|
|
321
405
|
return state;
|
|
322
406
|
}
|
|
323
407
|
catch {
|
|
324
|
-
// Keep scanning for an earlier valid
|
|
408
|
+
// Keep scanning for an earlier valid v3 snapshot.
|
|
325
409
|
}
|
|
326
410
|
}
|
|
327
411
|
return cloneTodoState(EMPTY_TODO_STATE);
|