@gamflaz/opencode-todolist 1.0.11 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/icons/glyph.d.ts +77 -0
- package/dist/icons/glyph.js +103 -0
- package/dist/icons/glyphs.d.ts +51 -0
- package/dist/icons/glyphs.js +56 -0
- package/dist/icons/svg-icon.js +12 -11
- package/dist/index.d.ts +21 -2
- package/dist/index.js +41 -3
- package/dist/schemas/todo.d.ts +76 -58
- package/dist/schemas/todo.js +73 -18
- package/dist/storage/db.d.ts +71 -0
- package/dist/storage/db.js +59 -0
- package/dist/storage/migrate-json.d.ts +82 -0
- package/dist/storage/migrate-json.js +278 -0
- package/dist/storage/schema.d.ts +775 -0
- package/dist/storage/schema.js +108 -0
- package/dist/storage/todo-store.d.ts +104 -56
- package/dist/storage/todo-store.js +178 -297
- package/dist/todos/hint.d.ts +13 -2
- package/dist/todos/hint.js +16 -19
- package/dist/todos/todo-tree.d.ts +16 -3
- package/dist/todos/todo-tree.js +22 -4
- package/dist/todos/update.js +2 -2
- package/dist/tui/todo-panel.js +77 -69
- package/drizzle/20261014050406_eminent_zaran/migration.sql +49 -0
- package/drizzle/20261014050406_eminent_zaran/snapshot.json +470 -0
- package/package.json +8 -5
|
@@ -1,27 +1,18 @@
|
|
|
1
|
-
import { createHash
|
|
2
|
-
import { existsSync
|
|
3
|
-
import { chmod, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
4
3
|
import { homedir, tmpdir } from "node:os";
|
|
5
4
|
import { join, posix, win32 } from "node:path";
|
|
6
|
-
import
|
|
7
|
-
import { TodoRootItemSchema } from "../schemas/todo.js";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
8
6
|
import { TodoTree } from "../todos/todo-tree.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* Layout (per session):
|
|
14
|
-
* <base>/s_<hash>/todos/todos-<timestamp>.json todo payload (latest wins)
|
|
15
|
-
* <base>/s_<hash>/todos/version monotonically increasing version
|
|
16
|
-
* <base>/s_<hash>/todos/id hashed session folder name
|
|
17
|
-
* <base>/s_<hash>/todos/todos.lock cross-process lock
|
|
18
|
-
* <base>/s_<hash>/todos/hint-marker.json hint injection marker
|
|
19
|
-
*/
|
|
7
|
+
import { getDatabase, getDataVersion as readDataVersion, resetDatabaseForTests, resolveMigrationsFolder, } from "./db.js";
|
|
8
|
+
import { importLegacyJson, mutateSessionTodos, readRevision, replaceSessionTodos, selectTodos, } from "./migrate-json.js";
|
|
9
|
+
import { todoSession } from "./schema.js";
|
|
10
|
+
const FILE_NAME = "todos.db";
|
|
20
11
|
export class TodoStore {
|
|
21
12
|
ctx;
|
|
22
13
|
static SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
23
|
-
|
|
24
|
-
|
|
14
|
+
handle;
|
|
15
|
+
importedBaseDir;
|
|
25
16
|
constructor(ctx = {}) {
|
|
26
17
|
this.ctx = ctx;
|
|
27
18
|
}
|
|
@@ -40,18 +31,32 @@ export class TodoStore {
|
|
|
40
31
|
return normalized;
|
|
41
32
|
}
|
|
42
33
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
34
|
+
* Storage partition key for a session: the OpenCode session id verbatim.
|
|
35
|
+
*
|
|
36
|
+
* The id is already unique and `normalizeSessionID` already restricts it to
|
|
37
|
+
* `[A-Za-z0-9_-]`, so hashing it bought nothing and cost readability — a raw
|
|
38
|
+
* id can be traced back to a session by eye, a digest cannot.
|
|
46
39
|
*/
|
|
47
|
-
static
|
|
40
|
+
static sessionKey(sessionID) {
|
|
41
|
+
return TodoStore.normalizeSessionID(sessionID);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The key older builds used for this session, kept so their rows still work.
|
|
45
|
+
*
|
|
46
|
+
* A SHA-256 digest cannot be reversed, so a stored hash can never be turned
|
|
47
|
+
* back into an id. It does not need to be: hashing is deterministic, so given
|
|
48
|
+
* the id we recompute the same digest and find the old row, which is what
|
|
49
|
+
* `resolveKey` does. A session that already has rows keeps its old key; only
|
|
50
|
+
* sessions that are new get the readable one.
|
|
51
|
+
*/
|
|
52
|
+
static legacyKey(sessionID) {
|
|
48
53
|
const normalized = TodoStore.normalizeSessionID(sessionID);
|
|
49
54
|
const digest = createHash("sha256").update(normalized).digest("hex");
|
|
50
55
|
return `s_${digest.slice(0, 32)}`;
|
|
51
56
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
/** @deprecated Use {@link TodoStore.legacyKey}; kept for existing callers. */
|
|
58
|
+
static hashSessionID(sessionID) {
|
|
59
|
+
return TodoStore.legacyKey(sessionID);
|
|
55
60
|
}
|
|
56
61
|
// Platform / directory resolution
|
|
57
62
|
get env() {
|
|
@@ -65,22 +70,11 @@ export class TodoStore {
|
|
|
65
70
|
? win32.join(...parts)
|
|
66
71
|
: posix.join(...parts);
|
|
67
72
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Cross-platform home directory resolution.
|
|
70
|
-
*
|
|
71
|
-
* `os.homedir()` already adapts to the OS, but it can return an empty string
|
|
72
|
-
* in stripped environments, and Windows shells sometimes expose a
|
|
73
|
-
* POSIX-style `HOME` (e.g. Git Bash `/c/Users/...`). We therefore prefer the
|
|
74
|
-
* canonical variable for the running platform before falling back.
|
|
75
|
-
*
|
|
76
|
-
* @see {@link https://nodejs.org/api/os.html#oshomedir}
|
|
77
|
-
*/
|
|
78
73
|
resolveHomeDir() {
|
|
79
74
|
const { env, platform } = this;
|
|
80
75
|
if (this.ctx.home)
|
|
81
76
|
return this.ctx.home;
|
|
82
77
|
if (platform === "win32") {
|
|
83
|
-
// Windows: USERPROFILE is canonical; HOMEDRIVE+HOMEPATH is the legacy pair.
|
|
84
78
|
if (env.USERPROFILE)
|
|
85
79
|
return env.USERPROFILE;
|
|
86
80
|
if (env.HOMEDRIVE && env.HOMEPATH)
|
|
@@ -89,7 +83,6 @@ export class TodoStore {
|
|
|
89
83
|
return env.HOME;
|
|
90
84
|
}
|
|
91
85
|
else {
|
|
92
|
-
// Linux, macOS, BSD, etc.
|
|
93
86
|
if (env.HOME)
|
|
94
87
|
return env.HOME;
|
|
95
88
|
if (env.USERPROFILE)
|
|
@@ -102,16 +95,6 @@ export class TodoStore {
|
|
|
102
95
|
return tmpdir();
|
|
103
96
|
}
|
|
104
97
|
}
|
|
105
|
-
/**
|
|
106
|
-
* Platform-native base directory for user configuration/data.
|
|
107
|
-
*
|
|
108
|
-
* - Windows : `%APPDATA%` (fallback `%LOCALAPPDATA%` → `~/AppData/Roaming`)
|
|
109
|
-
* - macOS : `~/Library/Application Support`
|
|
110
|
-
* - Linux : `$XDG_CONFIG_HOME` (fallback `~/.config`)
|
|
111
|
-
*
|
|
112
|
-
* `XDG_CONFIG_HOME` is honored first on every platform so users who prefer an
|
|
113
|
-
* XDG layout (including those running OpenCode's own convention) can force it.
|
|
114
|
-
*/
|
|
115
98
|
resolveConfigHome() {
|
|
116
99
|
const { env, platform } = this;
|
|
117
100
|
if (env.XDG_CONFIG_HOME)
|
|
@@ -129,32 +112,36 @@ export class TodoStore {
|
|
|
129
112
|
return this.joinForPlatform(this.resolveHomeDir(), ".config");
|
|
130
113
|
}
|
|
131
114
|
/**
|
|
132
|
-
* Base directory
|
|
115
|
+
* Base directory holding the database and the legacy session folders.
|
|
133
116
|
*
|
|
134
117
|
* Priority:
|
|
135
|
-
* 1. `OPENCODE_TODOS_DIR` — explicit
|
|
136
|
-
* 2.
|
|
118
|
+
* 1. `OPENCODE_TODOS_DIR` — explicit override for tests and tooling.
|
|
119
|
+
* 2. `<home>/.opencode/sessions`
|
|
137
120
|
*/
|
|
138
121
|
resolveTodosBaseDir() {
|
|
139
122
|
if (this.env.OPENCODE_TODOS_DIR)
|
|
140
123
|
return this.env.OPENCODE_TODOS_DIR;
|
|
141
124
|
return this.joinForPlatform(this.resolveHomeDir(), ".opencode", "sessions");
|
|
142
125
|
}
|
|
143
|
-
/** Base directory for all sessions (replaces the old `TODOS_DIRECTORY` const). */
|
|
144
126
|
get todosRoot() {
|
|
145
127
|
return this.getTodosDirectory();
|
|
146
128
|
}
|
|
147
|
-
|
|
129
|
+
getDatabasePath() {
|
|
130
|
+
return this.joinForPlatform(this.resolveTodosBaseDir(), FILE_NAME);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Legacy per-session directory.
|
|
134
|
+
*
|
|
135
|
+
* No longer written to. Kept because it is still read once to import old JSON
|
|
136
|
+
* and because the TUI watches this directory for filesystem changes.
|
|
137
|
+
*/
|
|
148
138
|
getSessionDirectory(sessionID) {
|
|
149
139
|
const baseDir = this.resolveTodosBaseDir();
|
|
150
140
|
const safeSessionID = TodoStore.normalizeSessionID(sessionID);
|
|
151
141
|
const hashedDir = join(baseDir, TodoStore.hashSessionID(safeSessionID));
|
|
152
|
-
// Backward compatibility: if an unhashed legacy directory exists, prefer it
|
|
153
|
-
// until it gets migrated or used.
|
|
154
142
|
const legacyDir = join(baseDir, safeSessionID);
|
|
155
|
-
if (!existsSync(hashedDir) && existsSync(legacyDir))
|
|
143
|
+
if (!existsSync(hashedDir) && existsSync(legacyDir))
|
|
156
144
|
return legacyDir;
|
|
157
|
-
}
|
|
158
145
|
return hashedDir;
|
|
159
146
|
}
|
|
160
147
|
getTodosDirectory(sessionID) {
|
|
@@ -163,279 +150,169 @@ export class TodoStore {
|
|
|
163
150
|
return baseDir;
|
|
164
151
|
return join(this.getSessionDirectory(sessionID), "todos");
|
|
165
152
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Path of the legacy hint marker.
|
|
155
|
+
*
|
|
156
|
+
* The marker now lives in `todo_session.hint_revision`, so nothing writes
|
|
157
|
+
* here. The path is still exposed because callers may need to locate and
|
|
158
|
+
* clean up a leftover file from an older install.
|
|
159
|
+
*/
|
|
170
160
|
getHintMarkerPath(sessionID) {
|
|
171
161
|
const safeSessionID = TodoStore.normalizeSessionID(sessionID);
|
|
172
162
|
return join(this.getTodosDirectory(safeSessionID), "hint-marker.json");
|
|
173
163
|
}
|
|
174
|
-
|
|
175
|
-
|
|
164
|
+
// Connection
|
|
165
|
+
database() {
|
|
166
|
+
if (!this.handle) {
|
|
167
|
+
this.handle = getDatabase(this.getDatabasePath());
|
|
168
|
+
}
|
|
169
|
+
// Import legacy JSON once per base directory for this process.
|
|
170
|
+
const baseDir = this.resolveTodosBaseDir();
|
|
171
|
+
if (this.importedBaseDir !== baseDir) {
|
|
172
|
+
importLegacyJson(this.handle, baseDir);
|
|
173
|
+
this.importedBaseDir = baseDir;
|
|
174
|
+
}
|
|
175
|
+
return this.handle;
|
|
176
176
|
}
|
|
177
|
-
|
|
178
|
-
return
|
|
177
|
+
key(sessionID) {
|
|
178
|
+
return TodoStore.sessionKey(sessionID);
|
|
179
179
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Resolves the key a session's rows currently live under.
|
|
182
|
+
*
|
|
183
|
+
* Prefers the real session id. A session first written by an older build
|
|
184
|
+
* still sits under its hashed key, so that is checked as a fallback — without
|
|
185
|
+
* it, upgrading would look like every todo list had vanished.
|
|
186
|
+
*/
|
|
187
|
+
resolveKey(sessionID) {
|
|
188
|
+
const handle = this.database();
|
|
189
|
+
const key = this.key(sessionID);
|
|
190
|
+
const found = handle.db
|
|
191
|
+
.select({ sessionKey: todoSession.sessionKey })
|
|
192
|
+
.from(todoSession)
|
|
193
|
+
.where(eq(todoSession.sessionKey, key))
|
|
194
|
+
.get();
|
|
195
|
+
if (found)
|
|
196
|
+
return key;
|
|
197
|
+
const legacy = TodoStore.legacyKey(sessionID);
|
|
198
|
+
const legacyFound = handle.db
|
|
199
|
+
.select({ sessionKey: todoSession.sessionKey })
|
|
200
|
+
.from(todoSession)
|
|
201
|
+
.where(eq(todoSession.sessionKey, legacy))
|
|
202
|
+
.get();
|
|
203
|
+
return legacyFound ? legacy : key;
|
|
184
204
|
}
|
|
185
205
|
// Public read API
|
|
206
|
+
/**
|
|
207
|
+
* Reads a session's list.
|
|
208
|
+
*
|
|
209
|
+
* Synchronous at its core because `bun:sqlite` is synchronous; the `async`
|
|
210
|
+
* signature is kept so existing callers do not need to change.
|
|
211
|
+
*/
|
|
186
212
|
async readTodos(sessionID) {
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
return TodoStore.parseTodoFile(await readFile(filePath, "utf8"));
|
|
190
|
-
}
|
|
191
|
-
catch (error) {
|
|
192
|
-
return this.recoverFromReadError(error);
|
|
193
|
-
}
|
|
213
|
+
return this.readTodosSync(sessionID);
|
|
194
214
|
}
|
|
195
215
|
readTodosSync(sessionID) {
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
return TodoStore.parseTodoFile(readFileSync(filePath, "utf8"));
|
|
199
|
-
}
|
|
200
|
-
catch (error) {
|
|
201
|
-
return this.recoverFromReadError(error);
|
|
202
|
-
}
|
|
216
|
+
return selectTodos(this.database().db, this.resolveKey(sessionID));
|
|
203
217
|
}
|
|
204
218
|
readTodoData(sessionID) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
? data.todos
|
|
214
|
-
: Array.isArray(parsed)
|
|
215
|
-
? parsed
|
|
216
|
-
: []);
|
|
217
|
-
const legacyVersion = typeof data.version === "number" && Number.isSafeInteger(data.version)
|
|
218
|
-
? data.version
|
|
219
|
-
: 0;
|
|
220
|
-
const legacyCreatedAt = typeof data.createdAt === "string" ? data.createdAt : undefined;
|
|
221
|
-
const legacyUpdatedAt = typeof data.updatedAt === "string" ? data.updatedAt : undefined;
|
|
222
|
-
const updated_at = typeof data.updated_at === "string"
|
|
223
|
-
? data.updated_at
|
|
224
|
-
: (legacyUpdatedAt ?? new Date().toISOString());
|
|
225
|
-
const created_at = typeof data.created_at === "string"
|
|
226
|
-
? data.created_at
|
|
227
|
-
: (legacyCreatedAt ?? legacyUpdatedAt ?? updated_at);
|
|
228
|
-
return {
|
|
229
|
-
version: this.readVersionFile(safeSessionID) ?? legacyVersion,
|
|
230
|
-
todos,
|
|
231
|
-
created_at,
|
|
232
|
-
updated_at,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
219
|
+
const handle = this.database();
|
|
220
|
+
const key = this.resolveKey(sessionID);
|
|
221
|
+
const session = handle.db
|
|
222
|
+
.select()
|
|
223
|
+
.from(todoSession)
|
|
224
|
+
.where(eq(todoSession.sessionKey, key))
|
|
225
|
+
.get();
|
|
226
|
+
if (!session)
|
|
236
227
|
return null;
|
|
228
|
+
const todos = selectTodos(handle.db, key);
|
|
229
|
+
return {
|
|
230
|
+
version: session.revision,
|
|
231
|
+
todos,
|
|
232
|
+
created_at: session.createdAt,
|
|
233
|
+
updated_at: session.updatedAt,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Revision counter, used by the TUI to detect changes cheaply. */
|
|
237
|
+
getRevision(sessionID) {
|
|
238
|
+
return readRevision(this.database(), this.resolveKey(sessionID));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Reads the hint marker value and, when it is stale, records the new
|
|
242
|
+
* revision. Returns the previous revision so the caller can decide.
|
|
243
|
+
*/
|
|
244
|
+
takeHintRevision(sessionID, currentRevision) {
|
|
245
|
+
const handle = this.database();
|
|
246
|
+
const key = this.resolveKey(sessionID);
|
|
247
|
+
const session = handle.db
|
|
248
|
+
.select({ hintRevision: todoSession.hintRevision })
|
|
249
|
+
.from(todoSession)
|
|
250
|
+
.where(eq(todoSession.sessionKey, key))
|
|
251
|
+
.get();
|
|
252
|
+
const previous = session?.hintRevision ?? 0;
|
|
253
|
+
if (previous !== currentRevision) {
|
|
254
|
+
handle.db
|
|
255
|
+
.update(todoSession)
|
|
256
|
+
.set({ hintRevision: currentRevision })
|
|
257
|
+
.where(eq(todoSession.sessionKey, key))
|
|
258
|
+
.run();
|
|
237
259
|
}
|
|
260
|
+
return previous;
|
|
261
|
+
}
|
|
262
|
+
/** Cross-process change signal for the TUI watcher. */
|
|
263
|
+
getDataVersion() {
|
|
264
|
+
return readDataVersion(this.database().sqlite);
|
|
265
|
+
}
|
|
266
|
+
migrationsFolder() {
|
|
267
|
+
return resolveMigrationsFolder();
|
|
238
268
|
}
|
|
239
269
|
// Public write API
|
|
240
270
|
async writeTodos(sessionID, todos) {
|
|
241
271
|
await this.updateTodos(sessionID, () => todos);
|
|
242
272
|
}
|
|
243
273
|
async updateTodos(sessionID, update) {
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
await rename(legacyDir, targetDir);
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
// Fallback: if rename fails (e.g. cross-device), continue using legacyDir
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
const sessionRoot = this.getSessionDirectory(safeSessionID);
|
|
258
|
-
const sessionDir = this.getTodosDirectory(safeSessionID);
|
|
259
|
-
await mkdir(sessionDir, { recursive: true });
|
|
260
|
-
await TodoStore.setPrivateMode(sessionRoot, 0o700);
|
|
261
|
-
await TodoStore.setPrivateMode(sessionDir, 0o700);
|
|
262
|
-
await this.migrateFlatTodoFiles(sessionRoot, sessionDir);
|
|
263
|
-
const release = await lockfile.lock(this.getTodoFilePath(safeSessionID), {
|
|
264
|
-
realpath: false,
|
|
265
|
-
lockfilePath: this.getTodoLockPath(safeSessionID),
|
|
266
|
-
retries: { retries: 10, minTimeout: 10, maxTimeout: 250 },
|
|
267
|
-
stale: 10_000,
|
|
268
|
-
});
|
|
269
|
-
try {
|
|
270
|
-
const filePath = this.getTodoFilePath(safeSessionID);
|
|
271
|
-
let current;
|
|
272
|
-
try {
|
|
273
|
-
current = await this.readTodos(safeSessionID);
|
|
274
|
-
}
|
|
275
|
-
catch (error) {
|
|
276
|
-
if (!(error instanceof InvalidTodoDataError))
|
|
277
|
-
throw error;
|
|
278
|
-
// Archive the corrupt file and start from an empty list.
|
|
279
|
-
const archivedPath = `${filePath}.corrupt-${Date.now()}`;
|
|
280
|
-
const archived = await rename(filePath, archivedPath).then(() => true, () => false);
|
|
281
|
-
if (archived)
|
|
282
|
-
await TodoStore.setPrivateMode(archivedPath, 0o600);
|
|
283
|
-
current = [];
|
|
284
|
-
}
|
|
274
|
+
const handle = this.database();
|
|
275
|
+
const key = this.resolveKey(sessionID);
|
|
276
|
+
// One transaction covers read, transform, and write. Splitting them would
|
|
277
|
+
// allow a lost update when the tool process and the widget process write
|
|
278
|
+
// concurrently: both could read revision N and then both write N+1.
|
|
279
|
+
return mutateSessionTodos(handle, key, (current) => {
|
|
285
280
|
const result = new TodoTree(update(current)).validateAndNormalize();
|
|
286
281
|
if (!result.valid) {
|
|
282
|
+
// Throwing inside the transaction rolls it back, so an invalid list
|
|
283
|
+
// never reaches disk and never bumps the revision.
|
|
287
284
|
throw new Error(`[todo store validation failed] ${result.error}`);
|
|
288
285
|
}
|
|
289
|
-
const existingData = this.readTodoData(safeSessionID);
|
|
290
|
-
const now = new Date().toISOString();
|
|
291
|
-
const nextVersion = (existingData?.version ?? 0) + 1;
|
|
292
|
-
const payload = {
|
|
293
|
-
created_at: existingData?.created_at ?? now,
|
|
294
|
-
updated_at: now,
|
|
295
|
-
todos: result.data,
|
|
296
|
-
};
|
|
297
|
-
await this.writeJsonAtomically(filePath, payload);
|
|
298
|
-
await this.writeTextAtomically(this.getVersionFilePath(safeSessionID), `${nextVersion}\n`);
|
|
299
|
-
await this.writeTextAtomically(this.getSessionIDFilePath(safeSessionID), `${TodoStore.hashSessionID(safeSessionID)}\n`);
|
|
300
|
-
await rm(join(sessionDir, "session.json"), { force: true });
|
|
301
|
-
await rm(join(sessionRoot, "session.json"), { force: true });
|
|
302
286
|
return result.data;
|
|
303
|
-
}
|
|
304
|
-
finally {
|
|
305
|
-
await release();
|
|
306
|
-
}
|
|
287
|
+
}).todos;
|
|
307
288
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return readdirSync(directory)
|
|
312
|
-
.flatMap((name) => {
|
|
313
|
-
if (!name.startsWith(prefix) || !name.endsWith(suffix))
|
|
314
|
-
return [];
|
|
315
|
-
const timestamp = Number(name.slice(prefix.length, -suffix.length));
|
|
316
|
-
if (!Number.isSafeInteger(timestamp) || timestamp < 0)
|
|
317
|
-
return [];
|
|
318
|
-
return [{ path: join(directory, name), timestamp }];
|
|
319
|
-
})
|
|
320
|
-
.sort((left, right) => right.timestamp - left.timestamp);
|
|
289
|
+
/** Replaces a list without running the tree validation (import path). */
|
|
290
|
+
replaceTodosSync(sessionID, todos) {
|
|
291
|
+
replaceSessionTodos(this.database(), this.resolveKey(sessionID), todos);
|
|
321
292
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
throw error;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
// Oldest layout: flat files in the base directory, `todos-<sessionID>-<ts>.json`.
|
|
340
|
-
try {
|
|
341
|
-
return this.scanTodoFiles(this.getTodosDirectory(), `${TodoStore.FILE_PREFIX}${safeSessionID}-`);
|
|
342
|
-
}
|
|
343
|
-
catch (error) {
|
|
344
|
-
if (TodoStore.isMissingFile(error))
|
|
345
|
-
return [];
|
|
346
|
-
throw error;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
readVersionFile(sessionID) {
|
|
350
|
-
const safeSessionID = TodoStore.normalizeSessionID(sessionID);
|
|
351
|
-
const paths = [
|
|
352
|
-
this.getVersionFilePath(safeSessionID),
|
|
353
|
-
join(this.getSessionDirectory(safeSessionID), "version"),
|
|
354
|
-
];
|
|
355
|
-
for (const path of paths) {
|
|
356
|
-
try {
|
|
357
|
-
const version = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
358
|
-
if (Number.isSafeInteger(version) && version >= 0)
|
|
359
|
-
return version;
|
|
360
|
-
}
|
|
361
|
-
catch {
|
|
362
|
-
// try the next candidate
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return null;
|
|
366
|
-
}
|
|
367
|
-
static parseTodoFile(raw) {
|
|
368
|
-
try {
|
|
369
|
-
const parsed = JSON.parse(raw);
|
|
370
|
-
const list = Array.isArray(parsed)
|
|
371
|
-
? parsed
|
|
372
|
-
: typeof parsed === "object" && parsed !== null && "todos" in parsed
|
|
373
|
-
? parsed.todos
|
|
374
|
-
: undefined;
|
|
375
|
-
if (!Array.isArray(list))
|
|
376
|
-
throw new Error("todo list is missing");
|
|
377
|
-
const result = TodoRootItemSchema.array().safeParse(list);
|
|
378
|
-
if (!result.success) {
|
|
379
|
-
throw new Error(result.error.issues.map((i) => i.message).join(", "));
|
|
380
|
-
}
|
|
381
|
-
return result.data;
|
|
382
|
-
}
|
|
383
|
-
catch (err) {
|
|
384
|
-
throw new InvalidTodoDataError(`[todo store read failed] invalid todo data: ${err instanceof Error ? err.message : String(err)}`);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
/** Shared error policy for readTodos / readTodosSync. */
|
|
388
|
-
recoverFromReadError(error) {
|
|
389
|
-
if (TodoStore.isMissingFile(error))
|
|
390
|
-
return [];
|
|
391
|
-
if (error instanceof InvalidTodoDataError)
|
|
392
|
-
throw error;
|
|
393
|
-
throw new Error(`[todo store read failed] unable to read todo storage: ${error instanceof Error ? error.message : String(error)}`);
|
|
394
|
-
}
|
|
395
|
-
async writeTextAtomically(filePath, content) {
|
|
396
|
-
const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
397
|
-
try {
|
|
398
|
-
await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
399
|
-
await rename(temporary, filePath);
|
|
400
|
-
}
|
|
401
|
-
finally {
|
|
402
|
-
await rm(temporary, { force: true });
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
async writeJsonAtomically(filePath, data) {
|
|
406
|
-
await this.writeTextAtomically(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
407
|
-
}
|
|
408
|
-
async migrateFlatTodoFiles(sessionRoot, todosDir) {
|
|
409
|
-
let names;
|
|
410
|
-
try {
|
|
411
|
-
names = readdirSync(sessionRoot);
|
|
412
|
-
}
|
|
413
|
-
catch (error) {
|
|
414
|
-
if (TodoStore.isMissingFile(error))
|
|
415
|
-
return;
|
|
416
|
-
throw error;
|
|
417
|
-
}
|
|
418
|
-
const movable = new Set([
|
|
419
|
-
"id",
|
|
420
|
-
"version",
|
|
421
|
-
"todos.lock",
|
|
422
|
-
"hint-marker.json",
|
|
423
|
-
]);
|
|
424
|
-
for (const name of names) {
|
|
425
|
-
const isTodoFile = name.startsWith(TodoStore.FILE_PREFIX) &&
|
|
426
|
-
name.endsWith(TodoStore.FILE_SUFFIX);
|
|
427
|
-
if (!isTodoFile && !movable.has(name))
|
|
428
|
-
continue;
|
|
429
|
-
const to = join(todosDir, name);
|
|
430
|
-
if (existsSync(to))
|
|
431
|
-
continue;
|
|
432
|
-
await rename(join(sessionRoot, name), to).catch(() => { });
|
|
433
|
-
}
|
|
293
|
+
/**
|
|
294
|
+
* Drops the cached connection so the next call reopens from scratch.
|
|
295
|
+
*
|
|
296
|
+
* Only for tests: the store deliberately caches one connection per process
|
|
297
|
+
* (opening applies migrations and sets up WAL, which is not free). A test
|
|
298
|
+
* that points `OPENCODE_TODOS_DIR` at a new directory must reset, otherwise
|
|
299
|
+
* the previous connection keeps servicing the old, unlinked file.
|
|
300
|
+
*/
|
|
301
|
+
resetForTests() {
|
|
302
|
+
this.handle = undefined;
|
|
303
|
+
this.importedBaseDir = undefined;
|
|
304
|
+
// The connection itself is cached in `db.ts`, so closing it there is what
|
|
305
|
+
// actually releases the old file descriptor.
|
|
306
|
+
resetDatabaseForTests();
|
|
434
307
|
}
|
|
435
308
|
}
|
|
436
|
-
// Module-level
|
|
309
|
+
// Module-level singleton plus functional helpers for backward compatibility.
|
|
437
310
|
const defaultStore = new TodoStore();
|
|
438
311
|
export const getTodosDirectory = (sessionID) => defaultStore.getTodosDirectory(sessionID);
|
|
312
|
+
export const getDatabasePath = () => defaultStore.getDatabasePath();
|
|
313
|
+
export const sessionKey = (sessionID) => TodoStore.sessionKey(sessionID);
|
|
314
|
+
export const legacySessionKey = (sessionID) => TodoStore.legacyKey(sessionID);
|
|
315
|
+
/** @deprecated Use {@link legacySessionKey}; the hash is no longer the key. */
|
|
439
316
|
export const hashSessionID = (sessionID) => TodoStore.hashSessionID(sessionID);
|
|
440
317
|
export const readTodos = (sessionID) => defaultStore.readTodos(sessionID);
|
|
441
318
|
export const readTodosSync = (sessionID) => defaultStore.readTodosSync(sessionID);
|
|
@@ -443,3 +320,7 @@ export const writeTodos = (sessionID, todos) => defaultStore.writeTodos(sessionI
|
|
|
443
320
|
export const updateTodos = (sessionID, update) => defaultStore.updateTodos(sessionID, update);
|
|
444
321
|
export const readTodoData = (sessionID) => defaultStore.readTodoData(sessionID);
|
|
445
322
|
export const getHintMarkerPath = (sessionID) => defaultStore.getHintMarkerPath(sessionID);
|
|
323
|
+
export const getRevision = (sessionID) => defaultStore.getRevision(sessionID);
|
|
324
|
+
export const takeHintRevision = (sessionID, revision) => defaultStore.takeHintRevision(sessionID, revision);
|
|
325
|
+
export const getDataVersion = () => defaultStore.getDataVersion();
|
|
326
|
+
export const resetTodoStoreForTests = () => defaultStore.resetForTests();
|
package/dist/todos/hint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TodoChildren, TodoRootItem } from "../schemas/todo";
|
|
2
2
|
export declare const DEFAULT_STALE_MS: number;
|
|
3
3
|
export declare const CONTEXT_STALE_MS: number;
|
|
4
4
|
export declare const TODO_HINT: string;
|
|
@@ -7,8 +7,19 @@ export interface ConsumeTodoHintOptions {
|
|
|
7
7
|
staleMs?: number;
|
|
8
8
|
now?: number;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Returns the resume hint at most once per stored revision.
|
|
12
|
+
*
|
|
13
|
+
* The "already hinted" marker used to be a `hint-marker.json` file next to the
|
|
14
|
+
* todo payload. It now lives in `todo_session.hint_revision`, so the decision is
|
|
15
|
+
* a single row and cannot drift from the data it describes.
|
|
16
|
+
*
|
|
17
|
+
* The hint is suppressed when the list has no open item, or when the stored
|
|
18
|
+
* write is older than `staleMs` — a resumed session should not be told about
|
|
19
|
+
* work the user abandoned long ago.
|
|
20
|
+
*/
|
|
10
21
|
export declare function consumeTodoHint(sessionID: string, options?: ConsumeTodoHintOptions): string | undefined;
|
|
11
|
-
type Leaf = TodoRootItem |
|
|
22
|
+
type Leaf = TodoRootItem | TodoChildren;
|
|
12
23
|
export declare function flattenLeaves(todos: readonly TodoRootItem[]): Leaf[];
|
|
13
24
|
export declare function findNextTodo(todos: readonly TodoRootItem[]): Leaf | undefined;
|
|
14
25
|
export declare function findStuckParent(todos: readonly TodoRootItem[]): TodoRootItem | undefined;
|
package/dist/todos/hint.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getHintMarkerPath, readTodoData, TodoStore } from "../storage/todo-store.js";
|
|
1
|
+
import { readTodoData, takeHintRevision, TodoStore } from "../storage/todo-store.js";
|
|
3
2
|
import { TodoTree } from "./todo-tree.js";
|
|
4
3
|
export const DEFAULT_STALE_MS = 30 * 60 * 1000; // 30 minutes (prompt fallback)
|
|
5
4
|
export const CONTEXT_STALE_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
@@ -17,11 +16,17 @@ function isTaskActive(task) {
|
|
|
17
16
|
export function isAnyTodoActive(todos) {
|
|
18
17
|
return todos.some(isTaskActive);
|
|
19
18
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Returns the resume hint at most once per stored revision.
|
|
21
|
+
*
|
|
22
|
+
* The "already hinted" marker used to be a `hint-marker.json` file next to the
|
|
23
|
+
* todo payload. It now lives in `todo_session.hint_revision`, so the decision is
|
|
24
|
+
* a single row and cannot drift from the data it describes.
|
|
25
|
+
*
|
|
26
|
+
* The hint is suppressed when the list has no open item, or when the stored
|
|
27
|
+
* write is older than `staleMs` — a resumed session should not be told about
|
|
28
|
+
* work the user abandoned long ago.
|
|
29
|
+
*/
|
|
25
30
|
export function consumeTodoHint(sessionID, options) {
|
|
26
31
|
try {
|
|
27
32
|
if (!sessionID)
|
|
@@ -39,19 +44,11 @@ export function consumeTodoHint(sessionID, options) {
|
|
|
39
44
|
if (Number.isFinite(updatedAt) && now - updatedAt > staleMs) {
|
|
40
45
|
return undefined;
|
|
41
46
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (typeof marker?.version === "number")
|
|
47
|
-
hintedVersion = marker.version;
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
// Marker missing or corrupt
|
|
51
|
-
}
|
|
52
|
-
if (hintedVersion === state.version)
|
|
47
|
+
// Atomic: reads the previous marker and records this revision in one step,
|
|
48
|
+
// so two rapid calls cannot both emit the hint.
|
|
49
|
+
const previous = takeHintRevision(safeSessionID, state.version);
|
|
50
|
+
if (previous === state.version)
|
|
53
51
|
return undefined;
|
|
54
|
-
atomicWrite(markerFile, JSON.stringify({ version: state.version }));
|
|
55
52
|
return TODO_HINT;
|
|
56
53
|
}
|
|
57
54
|
catch {
|