@druumen/sessions-db 0.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/CHANGELOG.md +249 -0
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +250 -0
- package/cli/_write-helpers.mjs +99 -0
- package/cli/alias.mjs +115 -0
- package/cli/argparse.mjs +296 -0
- package/cli/close.mjs +116 -0
- package/cli/find.mjs +185 -0
- package/cli/format.mjs +277 -0
- package/cli/link-parent.mjs +133 -0
- package/cli/link.mjs +132 -0
- package/cli/rebuild.mjs +98 -0
- package/cli/sessions-db-session-start-main.mjs +454 -0
- package/cli/sessions-db-session-start.mjs +56 -0
- package/cli/sessions-db.mjs +119 -0
- package/cli/sweep.mjs +171 -0
- package/cli/tree.mjs +127 -0
- package/lib/git-context.mjs +479 -0
- package/lib/identity.mjs +616 -0
- package/lib/index.mjs +145 -0
- package/lib/init.mjs +185 -0
- package/lib/lock.mjs +86 -0
- package/lib/operations.mjs +490 -0
- package/lib/paths.mjs +199 -0
- package/lib/projection.mjs +496 -0
- package/lib/sanitize.mjs +131 -0
- package/lib/storage.mjs +759 -0
- package/lib/sweep.mjs +209 -0
- package/lib/transcript.mjs +230 -0
- package/lib/types.mjs +276 -0
- package/lib/uuid.mjs +116 -0
- package/lib/watch.mjs +217 -0
- package/package.json +53 -0
- package/types/git-context.d.mts +98 -0
- package/types/identity.d.mts +658 -0
- package/types/index.d.mts +10 -0
- package/types/index.d.ts +127 -0
- package/types/init.d.mts +53 -0
- package/types/lock.d.mts +18 -0
- package/types/operations.d.mts +204 -0
- package/types/paths.d.mts +54 -0
- package/types/projection.d.mts +79 -0
- package/types/sanitize.d.mts +39 -0
- package/types/storage.d.mts +276 -0
- package/types/sweep.d.mts +58 -0
- package/types/transcript.d.mts +59 -0
- package/types/types.d.mts +255 -0
- package/types/uuid.d.mts +17 -0
- package/types/watch.d.mts +33 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library-API operations for sessions-db.
|
|
3
|
+
*
|
|
4
|
+
* These wrap the storage primitives (`tryUpdateProjection` + `loadProjection`)
|
|
5
|
+
* with input validation, business invariants, and a uniform `{ ok, event_id?,
|
|
6
|
+
* error? }` result shape so callers (the CLI handlers AND library consumers
|
|
7
|
+
* such as cockpit) do not have to re-implement the same checks.
|
|
8
|
+
*
|
|
9
|
+
* Three contracts every operation here MUST honor:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Validate before write.** Each operation rejects invalid input and
|
|
12
|
+
* missing target sessions BEFORE appending to events.jsonl. We do not
|
|
13
|
+
* want the SSoT to grow `alias_set` events for non-existent sessions.
|
|
14
|
+
*
|
|
15
|
+
* 2. **Result shape.** Success → `{ ok: true, event_id: '<evt_...>' }`.
|
|
16
|
+
* Failure → `{ ok: false, error: '<message>' }`. Operations DO NOT
|
|
17
|
+
* throw for business-class failures (lock timeout, not-found, cycle).
|
|
18
|
+
* System-class failures (disk full, permission denied) are caught by
|
|
19
|
+
* `tryUpdateProjection` and returned as `{ ok: false }` too — operations
|
|
20
|
+
* preserve that shape rather than re-raising.
|
|
21
|
+
*
|
|
22
|
+
* 3. **Lock-safe.** Every operation that mutates the projection routes
|
|
23
|
+
* through `tryUpdateProjection`, which holds the projection lock across
|
|
24
|
+
* the load → apply → save cycle. Operations never themselves perform
|
|
25
|
+
* raw `appendEvent` / `saveProjection` outside that primitive.
|
|
26
|
+
*
|
|
27
|
+
* Adding a new operation: write a thin function that builds the canonical
|
|
28
|
+
* event payload, calls `tryUpdateProjection`, and returns `commitResult()`.
|
|
29
|
+
* Resist the urge to extend signatures with `--dry-run` or `--json` —
|
|
30
|
+
* those are CLI-display concerns; the library returns structured results
|
|
31
|
+
* and lets the caller render them.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { computeSweepTransitions } from './sweep.mjs';
|
|
35
|
+
import {
|
|
36
|
+
loadProjection,
|
|
37
|
+
newEvent,
|
|
38
|
+
tryUpdateProjection,
|
|
39
|
+
} from './storage.mjs';
|
|
40
|
+
|
|
41
|
+
const VALID_OUTCOMES = new Set([
|
|
42
|
+
'open',
|
|
43
|
+
'done',
|
|
44
|
+
'blocked',
|
|
45
|
+
'abandoned',
|
|
46
|
+
'merged',
|
|
47
|
+
'superseded',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const MAX_PARENT_CHAIN_DEPTH = 50;
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Internal helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve `{ rootPath, root, paths }` opts into a single object suitable
|
|
58
|
+
* for storage primitives. All fields are optional; when omitted we let
|
|
59
|
+
* storage fall back to its full Day 4 resolution chain (env → ascend →
|
|
60
|
+
* cwd/.dru-code).
|
|
61
|
+
*
|
|
62
|
+
* Storage's `resolvePaths` honors all three shapes (`paths` > `rootPath`
|
|
63
|
+
* > `root` > default), so we just pass them through. Callers picking
|
|
64
|
+
* `rootPath` (Day 4 form) get the canonical-filename layout; callers on
|
|
65
|
+
* the legacy `root` form keep the `tickets/_logs/` anchored layout.
|
|
66
|
+
*/
|
|
67
|
+
function storageOpts({ rootPath, root, paths } = {}) {
|
|
68
|
+
const out = {};
|
|
69
|
+
if (rootPath !== undefined) out.rootPath = rootPath;
|
|
70
|
+
if (root !== undefined) out.root = root;
|
|
71
|
+
if (paths !== undefined) out.paths = paths;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Verify a stable_id exists in the projection. Returns the matched session
|
|
77
|
+
* record on success or `{ ok: false, error }` on miss. Library consumers
|
|
78
|
+
* differentiate the miss via the `error` string; CLI wraps it in stderr +
|
|
79
|
+
* exit 1.
|
|
80
|
+
*/
|
|
81
|
+
async function ensureSessionExists(stableId, opts) {
|
|
82
|
+
const projection = await loadProjection(storageOpts(opts));
|
|
83
|
+
const session = projection.sessions && projection.sessions[stableId];
|
|
84
|
+
if (!session) {
|
|
85
|
+
return { ok: false, error: `stable_id not found: ${stableId}`, projection: null };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, projection, session };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build event + commit through tryUpdateProjection. Returns the canonical
|
|
92
|
+
* library-API result shape.
|
|
93
|
+
*/
|
|
94
|
+
async function commitOp({ op, stableId, payload, opts }) {
|
|
95
|
+
const event = newEvent({ op, stable_id: stableId, payload });
|
|
96
|
+
const result = await tryUpdateProjection(event, storageOpts(opts));
|
|
97
|
+
if (!result.ok) {
|
|
98
|
+
return { ok: false, error: result.error };
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, event_id: event.event_id };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Public operations
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Set or clear the human-readable alias on a session.
|
|
109
|
+
*
|
|
110
|
+
* Either `alias` (non-empty string) or `clear: true` must be provided —
|
|
111
|
+
* mutually exclusive. Validation matches the CLI's argparse behavior so the
|
|
112
|
+
* library consumer surface is symmetric with the CLI surface.
|
|
113
|
+
*
|
|
114
|
+
* @param {{
|
|
115
|
+
* stableId: string,
|
|
116
|
+
* alias?: string,
|
|
117
|
+
* clear?: boolean,
|
|
118
|
+
* rootPath?: string,
|
|
119
|
+
* root?: string,
|
|
120
|
+
* paths?: object,
|
|
121
|
+
* }} opts
|
|
122
|
+
* @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
|
|
123
|
+
*/
|
|
124
|
+
export async function setAlias(opts) {
|
|
125
|
+
if (!opts || typeof opts !== 'object') {
|
|
126
|
+
return { ok: false, error: 'setAlias: opts required' };
|
|
127
|
+
}
|
|
128
|
+
const { stableId, alias, clear } = opts;
|
|
129
|
+
if (typeof stableId !== 'string' || stableId.length === 0) {
|
|
130
|
+
return { ok: false, error: 'setAlias: stableId required' };
|
|
131
|
+
}
|
|
132
|
+
const wantsClear = clear === true;
|
|
133
|
+
const hasAlias = alias !== undefined && alias !== null;
|
|
134
|
+
if (wantsClear && hasAlias) {
|
|
135
|
+
return { ok: false, error: 'setAlias: alias and clear are mutually exclusive' };
|
|
136
|
+
}
|
|
137
|
+
if (!wantsClear && !hasAlias) {
|
|
138
|
+
return { ok: false, error: 'setAlias: provide alias or clear=true' };
|
|
139
|
+
}
|
|
140
|
+
if (hasAlias && (typeof alias !== 'string' || alias.length === 0)) {
|
|
141
|
+
return { ok: false, error: 'setAlias: alias must be a non-empty string' };
|
|
142
|
+
}
|
|
143
|
+
const exists = await ensureSessionExists(stableId, opts);
|
|
144
|
+
if (!exists.ok) return { ok: false, error: exists.error };
|
|
145
|
+
const payload = wantsClear ? { alias: null } : { alias };
|
|
146
|
+
return commitOp({ op: 'alias_set', stableId, payload, opts });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Link a session to one or more tasks / projects (additive, idempotent).
|
|
151
|
+
*
|
|
152
|
+
* At least one of `tasks` / `projects` must be a non-empty array. The
|
|
153
|
+
* reducer already de-dupes against existing entries so re-running with the
|
|
154
|
+
* same payload is a no-op on projection state (but still writes an audit
|
|
155
|
+
* event).
|
|
156
|
+
*
|
|
157
|
+
* @param {{
|
|
158
|
+
* stableId: string,
|
|
159
|
+
* tasks?: string[],
|
|
160
|
+
* projects?: string[],
|
|
161
|
+
* rootPath?: string,
|
|
162
|
+
* root?: string,
|
|
163
|
+
* paths?: object,
|
|
164
|
+
* }} opts
|
|
165
|
+
* @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
|
|
166
|
+
*/
|
|
167
|
+
export async function linkTask(opts) {
|
|
168
|
+
if (!opts || typeof opts !== 'object') {
|
|
169
|
+
return { ok: false, error: 'linkTask: opts required' };
|
|
170
|
+
}
|
|
171
|
+
const { stableId } = opts;
|
|
172
|
+
if (typeof stableId !== 'string' || stableId.length === 0) {
|
|
173
|
+
return { ok: false, error: 'linkTask: stableId required' };
|
|
174
|
+
}
|
|
175
|
+
const tasks = normalizeIdList(opts.tasks);
|
|
176
|
+
const projects = normalizeIdList(opts.projects);
|
|
177
|
+
if (tasks.length === 0 && projects.length === 0) {
|
|
178
|
+
return { ok: false, error: 'linkTask: provide at least one task or project' };
|
|
179
|
+
}
|
|
180
|
+
const exists = await ensureSessionExists(stableId, opts);
|
|
181
|
+
if (!exists.ok) return { ok: false, error: exists.error };
|
|
182
|
+
const payload = {};
|
|
183
|
+
if (tasks.length > 0) payload.tasks = tasks;
|
|
184
|
+
if (projects.length > 0) payload.projects = projects;
|
|
185
|
+
return commitOp({ op: 'session_link', stableId, payload, opts });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Unlink one or more tasks / projects from a session (set-based filter,
|
|
190
|
+
* idempotent). Removing an id that isn't present is a no-op on projection
|
|
191
|
+
* state but still produces an audit event — operator intent is recorded
|
|
192
|
+
* regardless of resulting state change.
|
|
193
|
+
*
|
|
194
|
+
* @param {{
|
|
195
|
+
* stableId: string,
|
|
196
|
+
* tasks?: string[],
|
|
197
|
+
* projects?: string[],
|
|
198
|
+
* rootPath?: string,
|
|
199
|
+
* root?: string,
|
|
200
|
+
* paths?: object,
|
|
201
|
+
* }} opts
|
|
202
|
+
* @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
|
|
203
|
+
*/
|
|
204
|
+
export async function unlinkTask(opts) {
|
|
205
|
+
if (!opts || typeof opts !== 'object') {
|
|
206
|
+
return { ok: false, error: 'unlinkTask: opts required' };
|
|
207
|
+
}
|
|
208
|
+
const { stableId } = opts;
|
|
209
|
+
if (typeof stableId !== 'string' || stableId.length === 0) {
|
|
210
|
+
return { ok: false, error: 'unlinkTask: stableId required' };
|
|
211
|
+
}
|
|
212
|
+
const tasks = normalizeIdList(opts.tasks);
|
|
213
|
+
const projects = normalizeIdList(opts.projects);
|
|
214
|
+
if (tasks.length === 0 && projects.length === 0) {
|
|
215
|
+
return { ok: false, error: 'unlinkTask: provide at least one task or project' };
|
|
216
|
+
}
|
|
217
|
+
const exists = await ensureSessionExists(stableId, opts);
|
|
218
|
+
if (!exists.ok) return { ok: false, error: exists.error };
|
|
219
|
+
const payload = {};
|
|
220
|
+
if (tasks.length > 0) payload.tasks = tasks;
|
|
221
|
+
if (projects.length > 0) payload.projects = projects;
|
|
222
|
+
return commitOp({ op: 'session_unlink', stableId, payload, opts });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Set or clear the hub-spoke parent relationship for a session.
|
|
227
|
+
*
|
|
228
|
+
* Either `parentId` (non-empty string, distinct from `childId`) or `clear:
|
|
229
|
+
* true` must be provided. When setting a parent we:
|
|
230
|
+
* - reject self-cycle (parentId === childId, exit-1 in CLI)
|
|
231
|
+
* - verify parent exists
|
|
232
|
+
* - walk parent's ancestor chain up to MAX_PARENT_CHAIN_DEPTH and reject
|
|
233
|
+
* if `childId` appears anywhere — that would close a cycle of length
|
|
234
|
+
* ≥ 2 (e.g. existing A→B + proposed `setParent({childId: B, parentId: A})`
|
|
235
|
+
* would form A→B→A).
|
|
236
|
+
*
|
|
237
|
+
* The MAX_PARENT_CHAIN_DEPTH bound is a defense against a stale projection
|
|
238
|
+
* cycle (rare; would require an earlier guard bypass). 50 is generous —
|
|
239
|
+
* real hub-spoke chains are 1-3 hops.
|
|
240
|
+
*
|
|
241
|
+
* @param {{
|
|
242
|
+
* childId: string,
|
|
243
|
+
* parentId?: string,
|
|
244
|
+
* clear?: boolean,
|
|
245
|
+
* rootPath?: string,
|
|
246
|
+
* root?: string,
|
|
247
|
+
* paths?: object,
|
|
248
|
+
* }} opts
|
|
249
|
+
* @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
|
|
250
|
+
*/
|
|
251
|
+
export async function setParent(opts) {
|
|
252
|
+
if (!opts || typeof opts !== 'object') {
|
|
253
|
+
return { ok: false, error: 'setParent: opts required' };
|
|
254
|
+
}
|
|
255
|
+
const { childId, parentId, clear } = opts;
|
|
256
|
+
if (typeof childId !== 'string' || childId.length === 0) {
|
|
257
|
+
return { ok: false, error: 'setParent: childId required' };
|
|
258
|
+
}
|
|
259
|
+
const wantsClear = clear === true;
|
|
260
|
+
const hasParent = parentId !== undefined && parentId !== null;
|
|
261
|
+
if (wantsClear && hasParent) {
|
|
262
|
+
return { ok: false, error: 'setParent: parentId and clear are mutually exclusive' };
|
|
263
|
+
}
|
|
264
|
+
if (!wantsClear && !hasParent) {
|
|
265
|
+
return { ok: false, error: 'setParent: provide parentId or clear=true' };
|
|
266
|
+
}
|
|
267
|
+
if (hasParent && (typeof parentId !== 'string' || parentId.length === 0)) {
|
|
268
|
+
return { ok: false, error: 'setParent: parentId must be a non-empty string' };
|
|
269
|
+
}
|
|
270
|
+
if (hasParent && parentId === childId) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: 'setParent: parent and child cannot be the same stable_id',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Verify child exists. Cycle detection must use the same projection load
|
|
278
|
+
// so the walk reflects what storage will see when the event commits.
|
|
279
|
+
const childCheck = await ensureSessionExists(childId, opts);
|
|
280
|
+
if (!childCheck.ok) return { ok: false, error: childCheck.error };
|
|
281
|
+
|
|
282
|
+
if (hasParent) {
|
|
283
|
+
const projection = childCheck.projection;
|
|
284
|
+
const parentSession = projection.sessions && projection.sessions[parentId];
|
|
285
|
+
if (!parentSession) {
|
|
286
|
+
return { ok: false, error: `stable_id not found: ${parentId}` };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Multi-hop cycle detection — walk parent's ancestor chain via
|
|
290
|
+
// parent_session_id pointers; refuse if we encounter `childId` along
|
|
291
|
+
// the way. The 1-cycle (parentId === childId) was already rejected.
|
|
292
|
+
let cursor = parentId;
|
|
293
|
+
for (let depth = 0; depth < MAX_PARENT_CHAIN_DEPTH && cursor; depth++) {
|
|
294
|
+
if (cursor === childId) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
error:
|
|
298
|
+
`setParent: would create a cycle: proposed parent ${parentId} ` +
|
|
299
|
+
`reaches child ${childId} after ${depth} hop(s)`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const ancestor = projection.sessions && projection.sessions[cursor];
|
|
303
|
+
cursor = ancestor && ancestor.parent_session_id
|
|
304
|
+
? ancestor.parent_session_id
|
|
305
|
+
: null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const payload = wantsClear
|
|
310
|
+
? { parent_session_id: null }
|
|
311
|
+
: { parent_session_id: parentId };
|
|
312
|
+
return commitOp({ op: 'parent_set', stableId: childId, payload, opts });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Close (or reopen) a session with a terminal outcome.
|
|
317
|
+
*
|
|
318
|
+
* Outcome enum is enforced (matches projection schema): open | done |
|
|
319
|
+
* blocked | abandoned | merged | superseded. `open` is allowed — operators
|
|
320
|
+
* may reopen a previously-closed session by passing `outcome: 'open'`; the
|
|
321
|
+
* reducer's closed_at always tracks the latest close event so the reopen is
|
|
322
|
+
* visible in the audit trail.
|
|
323
|
+
*
|
|
324
|
+
* @param {{
|
|
325
|
+
* stableId: string,
|
|
326
|
+
* outcome: string,
|
|
327
|
+
* reason?: string,
|
|
328
|
+
* rootPath?: string,
|
|
329
|
+
* root?: string,
|
|
330
|
+
* paths?: object,
|
|
331
|
+
* }} opts
|
|
332
|
+
* @returns {Promise<{ ok: boolean, event_id?: string, error?: string }>}
|
|
333
|
+
*/
|
|
334
|
+
export async function closeSession(opts) {
|
|
335
|
+
if (!opts || typeof opts !== 'object') {
|
|
336
|
+
return { ok: false, error: 'closeSession: opts required' };
|
|
337
|
+
}
|
|
338
|
+
const { stableId, outcome, reason } = opts;
|
|
339
|
+
if (typeof stableId !== 'string' || stableId.length === 0) {
|
|
340
|
+
return { ok: false, error: 'closeSession: stableId required' };
|
|
341
|
+
}
|
|
342
|
+
if (typeof outcome !== 'string' || outcome.length === 0) {
|
|
343
|
+
return { ok: false, error: 'closeSession: outcome required' };
|
|
344
|
+
}
|
|
345
|
+
if (!VALID_OUTCOMES.has(outcome)) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
error:
|
|
349
|
+
`closeSession: outcome must be one of: ` +
|
|
350
|
+
`${[...VALID_OUTCOMES].join(', ')}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (reason !== undefined && reason !== null && typeof reason !== 'string') {
|
|
354
|
+
return { ok: false, error: 'closeSession: reason must be a string' };
|
|
355
|
+
}
|
|
356
|
+
const exists = await ensureSessionExists(stableId, opts);
|
|
357
|
+
if (!exists.ok) return { ok: false, error: exists.error };
|
|
358
|
+
|
|
359
|
+
const payload = { outcome };
|
|
360
|
+
if (reason !== undefined) payload.closed_reason = reason;
|
|
361
|
+
return commitOp({ op: 'close', stableId, payload, opts });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Compute and (optionally) apply activity_state transitions across all
|
|
366
|
+
* sessions in the projection.
|
|
367
|
+
*
|
|
368
|
+
* Returns:
|
|
369
|
+
* - dryRun: true → `{ ok: true, dryRun: true, transitions }` with the
|
|
370
|
+
* planned transitions list (no events written).
|
|
371
|
+
* - dryRun: false → `{ ok: boolean, applied, failed, summary }` after
|
|
372
|
+
* attempting each transition through `tryUpdateProjection`. `ok` is
|
|
373
|
+
* true when zero failures.
|
|
374
|
+
*
|
|
375
|
+
* Lock model: each transition acquires the projection lock independently
|
|
376
|
+
* via `tryUpdateProjection`. For typical sweep volumes (single digits per
|
|
377
|
+
* run) this is fine; if the workspace grows huge a future `--batch` mode
|
|
378
|
+
* can fold all transitions into a single under-lock pass.
|
|
379
|
+
*
|
|
380
|
+
* @param {{
|
|
381
|
+
* rootPath?: string,
|
|
382
|
+
* root?: string,
|
|
383
|
+
* paths?: object,
|
|
384
|
+
* idleThresholdDays?: number,
|
|
385
|
+
* archiveThresholdDays?: number,
|
|
386
|
+
* dryRun?: boolean,
|
|
387
|
+
* now?: number,
|
|
388
|
+
* }} [opts]
|
|
389
|
+
* @returns {Promise<
|
|
390
|
+
* | { ok: true, dryRun: true, transitions: Array<object> }
|
|
391
|
+
* | { ok: boolean, applied: Array<object>, failed: Array<object>, summary: object }
|
|
392
|
+
* >}
|
|
393
|
+
*/
|
|
394
|
+
export async function runSweep(opts = {}) {
|
|
395
|
+
const idleThresholdDays = opts.idleThresholdDays;
|
|
396
|
+
const archiveThresholdDays = opts.archiveThresholdDays;
|
|
397
|
+
|
|
398
|
+
if (idleThresholdDays !== undefined
|
|
399
|
+
&& (!Number.isFinite(idleThresholdDays) || idleThresholdDays <= 0)) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
error: `runSweep: idleThresholdDays must be a positive number (got: ${idleThresholdDays})`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (archiveThresholdDays !== undefined
|
|
406
|
+
&& (!Number.isFinite(archiveThresholdDays) || archiveThresholdDays <= 0)) {
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
error: `runSweep: archiveThresholdDays must be a positive number (got: ${archiveThresholdDays})`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (idleThresholdDays !== undefined
|
|
413
|
+
&& archiveThresholdDays !== undefined
|
|
414
|
+
&& archiveThresholdDays < idleThresholdDays) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
error:
|
|
418
|
+
`runSweep: archiveThresholdDays (${archiveThresholdDays}) must be >= ` +
|
|
419
|
+
`idleThresholdDays (${idleThresholdDays})`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const projection = await loadProjection(storageOpts(opts));
|
|
424
|
+
const transitions = computeSweepTransitions(projection, {
|
|
425
|
+
idleThresholdDays,
|
|
426
|
+
archiveThresholdDays,
|
|
427
|
+
now: opts.now,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
if (opts.dryRun === true) {
|
|
431
|
+
return { ok: true, dryRun: true, transitions };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const applied = [];
|
|
435
|
+
const failed = [];
|
|
436
|
+
for (const t of transitions) {
|
|
437
|
+
const event = newEvent({
|
|
438
|
+
op: 'sweep',
|
|
439
|
+
stable_id: t.stable_id,
|
|
440
|
+
payload: {
|
|
441
|
+
activity_state: t.to_state,
|
|
442
|
+
effective_last_progress: t.effective_last_progress,
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
const result = await tryUpdateProjection(event, storageOpts(opts));
|
|
446
|
+
if (result.ok) {
|
|
447
|
+
applied.push({ ...t, event_id: event.event_id });
|
|
448
|
+
} else {
|
|
449
|
+
failed.push({ ...t, error: result.error });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const toIdle = applied.filter((a) => a.to_state === 'idle').length;
|
|
454
|
+
const toArchived = applied.filter((a) => a.to_state === 'archived').length;
|
|
455
|
+
return {
|
|
456
|
+
ok: failed.length === 0,
|
|
457
|
+
applied,
|
|
458
|
+
failed,
|
|
459
|
+
summary: {
|
|
460
|
+
total: transitions.length,
|
|
461
|
+
applied: applied.length,
|
|
462
|
+
failed: failed.length,
|
|
463
|
+
to_idle: toIdle,
|
|
464
|
+
to_archived: toArchived,
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Local utilities
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Coerce an id-list input into a deduped array of non-empty strings.
|
|
475
|
+
* Accepts undefined / null / single-string / array. Used by linkTask /
|
|
476
|
+
* unlinkTask so a caller passing `'foo'` instead of `['foo']` still works.
|
|
477
|
+
*/
|
|
478
|
+
function normalizeIdList(input) {
|
|
479
|
+
if (input === undefined || input === null) return [];
|
|
480
|
+
const arr = Array.isArray(input) ? input : [input];
|
|
481
|
+
const seen = new Set();
|
|
482
|
+
const out = [];
|
|
483
|
+
for (const v of arr) {
|
|
484
|
+
if (typeof v !== 'string' || v.length === 0) continue;
|
|
485
|
+
if (seen.has(v)) continue;
|
|
486
|
+
seen.add(v);
|
|
487
|
+
out.push(v);
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
}
|
package/lib/paths.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized storage-path resolution for sessions-db.
|
|
3
|
+
*
|
|
4
|
+
* Prior to Day 4, every storage primitive (`appendEvent`, `loadProjection`,
|
|
5
|
+
* `tryUpdateProjection`, `recordSessionSeen`, `initProjection`,
|
|
6
|
+
* `watchProjection`) hand-rolled its own "anchor opts.paths against opts.root
|
|
7
|
+
* or fall back to PATHS + cwd" path-joining logic. That worked while there
|
|
8
|
+
* was a single canonical layout (`tickets/_logs/`) and a single consumer
|
|
9
|
+
* (this monorepo), but it doesn't survive cockpit-marketplace users who:
|
|
10
|
+
* - don't have `tickets/_logs/` (no monorepo)
|
|
11
|
+
* - want a product-neutral default (`.dru-code/`)
|
|
12
|
+
* - need an env-var override for VS Code workspace overrides
|
|
13
|
+
* - might call from inside a child workspace dir and expect "find existing
|
|
14
|
+
* storage upward" instead of accidentally creating a parallel one
|
|
15
|
+
*
|
|
16
|
+
* `resolveStoragePaths(opts)` collapses all five priorities into one entry
|
|
17
|
+
* point. First hit wins:
|
|
18
|
+
*
|
|
19
|
+
* 1. opts.rootPath — explicit caller arg (highest priority; tests + library
|
|
20
|
+
* consumers that already know exactly where storage lives)
|
|
21
|
+
* 2. process.env.DRUUMEN_SESSIONS_DB_ROOT — env var override (cockpit
|
|
22
|
+
* Setup Wizard writes this, CI overrides it, ops can pin during incidents)
|
|
23
|
+
* 3. cwd-ascend (bounded) for an existing `tickets/_logs/sessions-db.json`
|
|
24
|
+
* — preserves the druumen monorepo experience: running any sessions-db
|
|
25
|
+
* command from anywhere inside the worktree finds the canonical
|
|
26
|
+
* tickets/_logs/ root just like the previous hand-rolled cwd-anchor did.
|
|
27
|
+
* 4. cwd-ascend (bounded) for an existing `.dru-code/sessions-db.json` —
|
|
28
|
+
* the new convention for fresh installs that have already been
|
|
29
|
+
* initialized once.
|
|
30
|
+
* 5. Default new: `<cwd>/.dru-code/` — what fresh `initProjection({})`
|
|
31
|
+
* lands when no existing storage is found. Cockpit marketplace's first
|
|
32
|
+
* install creates this dir.
|
|
33
|
+
*
|
|
34
|
+
* Layout invariant inside `<root>/`:
|
|
35
|
+
* - sessions-db-events.jsonl — append-only SSoT
|
|
36
|
+
* - sessions-db.json — projection cache
|
|
37
|
+
* - sessions-db.json.lock — exclusive-create lockfile
|
|
38
|
+
*
|
|
39
|
+
* The same three filenames are used for both druumen-monorepo
|
|
40
|
+
* (`tickets/_logs/`) and `.dru-code/` layouts so callers never need a
|
|
41
|
+
* layout-conditional path computation.
|
|
42
|
+
*
|
|
43
|
+
* Why an ascend bound (MAX_ASCEND_DEPTH=12)? Walking to filesystem `/` is
|
|
44
|
+
* slow on networked mounts and pointless — anyone keeping their workspace
|
|
45
|
+
* 12 directories deep is doing something unusual and should set
|
|
46
|
+
* `DRUUMEN_SESSIONS_DB_ROOT` explicitly. The bound caps the worst-case stat
|
|
47
|
+
* count at 12 × 2 (two candidate file checks per level) = 24 stats per call.
|
|
48
|
+
*
|
|
49
|
+
* Zero new runtime deps: `node:fs`, `node:path`. Same as the rest of lib/.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { existsSync } from 'node:fs';
|
|
53
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hard cap on cwd-ascend depth. Twelve levels is generous — a typical
|
|
57
|
+
* worktree depth is 1-3, monorepos may go to 5-6. Pinning at 12 means the
|
|
58
|
+
* worst-case stat budget is 24 (two candidate paths × 12 levels) before
|
|
59
|
+
* we fall through to the default. Set deliberately conservative so the
|
|
60
|
+
* resolver never accidentally walks to `/` on a slow networked mount.
|
|
61
|
+
*/
|
|
62
|
+
export const MAX_ASCEND_DEPTH = 12;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The three on-disk filenames (relative to whichever root the resolver
|
|
66
|
+
* picks). Frozen so callers can't accidentally mutate. Exported for tests
|
|
67
|
+
* + the rare library consumer that wants to know the canonical names.
|
|
68
|
+
*/
|
|
69
|
+
export const STORAGE_FILENAMES = Object.freeze({
|
|
70
|
+
eventsJsonl: 'sessions-db-events.jsonl',
|
|
71
|
+
projectionJson: 'sessions-db.json',
|
|
72
|
+
lockFile: 'sessions-db.json.lock',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve storage paths from caller opts + env + autodiscover.
|
|
77
|
+
*
|
|
78
|
+
* @param {{ rootPath?: string, cwd?: string }} [opts]
|
|
79
|
+
* @returns {{
|
|
80
|
+
* root: string,
|
|
81
|
+
* eventsJsonl: string,
|
|
82
|
+
* projectionJson: string,
|
|
83
|
+
* lockFile: string,
|
|
84
|
+
* source: 'arg' | 'env' | 'tickets-logs' | 'dru-code' | 'default',
|
|
85
|
+
* }}
|
|
86
|
+
*/
|
|
87
|
+
export function resolveStoragePaths(opts = {}) {
|
|
88
|
+
// ----- Priority 1: explicit opts.rootPath -----
|
|
89
|
+
// Tests + library consumers that already pinned the location pass this.
|
|
90
|
+
// Resolved against process.cwd() so a relative override (`./tmp/db`) still
|
|
91
|
+
// produces an absolute path the rest of the library can use.
|
|
92
|
+
if (typeof opts.rootPath === 'string' && opts.rootPath.length > 0) {
|
|
93
|
+
const root = resolve(opts.rootPath);
|
|
94
|
+
return { root, ...buildFilePaths(root), source: 'arg' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ----- Priority 2: env var -----
|
|
98
|
+
// DRUUMEN_SESSIONS_DB_ROOT is the documented escape hatch for ops /
|
|
99
|
+
// cockpit Setup Wizard / CI matrix runs. Empty string is treated as
|
|
100
|
+
// "not set" so `DRUUMEN_SESSIONS_DB_ROOT=` in a half-configured env file
|
|
101
|
+
// doesn't silently send writes to `/sessions-db-events.jsonl`.
|
|
102
|
+
const envRoot = process.env.DRUUMEN_SESSIONS_DB_ROOT;
|
|
103
|
+
if (typeof envRoot === 'string' && envRoot.length > 0) {
|
|
104
|
+
const root = resolve(envRoot);
|
|
105
|
+
return { root, ...buildFilePaths(root), source: 'env' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ----- Priorities 3 + 4: cwd-ascend for existing storage -----
|
|
109
|
+
// We walk upward from opts.cwd (or process.cwd) checking for either a
|
|
110
|
+
// legacy druumen-monorepo `tickets/_logs/sessions-db.json` (priority 3)
|
|
111
|
+
// OR a new-convention `.dru-code/sessions-db.json` (priority 4). At each
|
|
112
|
+
// level the legacy check runs first — when both exist somehow, the
|
|
113
|
+
// existing-data location wins so we never silently bifurcate writes.
|
|
114
|
+
const startCwd = resolve(
|
|
115
|
+
typeof opts.cwd === 'string' && opts.cwd.length > 0 ? opts.cwd : process.cwd(),
|
|
116
|
+
);
|
|
117
|
+
const found = ascendForExistingDb(startCwd);
|
|
118
|
+
if (found) {
|
|
119
|
+
return { root: found.root, ...buildFilePaths(found.root), source: found.source };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ----- Priority 5: new default `<cwd>/.dru-code/` -----
|
|
123
|
+
// Fresh-install case. `initProjection` will mkdir this; until it does,
|
|
124
|
+
// the path is virtual (just where future writes will land).
|
|
125
|
+
const defaultRoot = join(startCwd, '.dru-code');
|
|
126
|
+
return { root: defaultRoot, ...buildFilePaths(defaultRoot), source: 'default' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build absolute file paths from a root directory. Assumes `root` is already
|
|
131
|
+
* absolute (callers in this module always resolve before calling).
|
|
132
|
+
*
|
|
133
|
+
* @param {string} root
|
|
134
|
+
* @returns {{ eventsJsonl: string, projectionJson: string, lockFile: string }}
|
|
135
|
+
*/
|
|
136
|
+
function buildFilePaths(root) {
|
|
137
|
+
return {
|
|
138
|
+
eventsJsonl: join(root, STORAGE_FILENAMES.eventsJsonl),
|
|
139
|
+
projectionJson: join(root, STORAGE_FILENAMES.projectionJson),
|
|
140
|
+
lockFile: join(root, STORAGE_FILENAMES.lockFile),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Walk upward from `startCwd` looking for an existing sessions-db storage
|
|
146
|
+
* dir. Returns `{ root, source }` on first hit, null after MAX_ASCEND_DEPTH
|
|
147
|
+
* levels or when reaching the filesystem root.
|
|
148
|
+
*
|
|
149
|
+
* Order at each level:
|
|
150
|
+
* - tickets/_logs/sessions-db.json (druumen-monorepo legacy)
|
|
151
|
+
* - .dru-code/sessions-db.json (new convention)
|
|
152
|
+
*
|
|
153
|
+
* Why projection-file existence (not directory existence)? An empty
|
|
154
|
+
* `tickets/_logs/` or `.dru-code/` directory could legitimately predate
|
|
155
|
+
* sessions-db (e.g. another tool created it). Using the projection file as
|
|
156
|
+
* the existence signal guarantees we only adopt locations that already have
|
|
157
|
+
* sessions-db state — never sibling tools' storage dirs.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} startCwd absolute path
|
|
160
|
+
* @returns {{ root: string, source: 'tickets-logs' | 'dru-code' } | null}
|
|
161
|
+
*/
|
|
162
|
+
function ascendForExistingDb(startCwd) {
|
|
163
|
+
let cwd = startCwd;
|
|
164
|
+
for (let depth = 0; depth < MAX_ASCEND_DEPTH; depth++) {
|
|
165
|
+
// Priority 3: druumen monorepo convention
|
|
166
|
+
const ticketsLogsRoot = join(cwd, 'tickets', '_logs');
|
|
167
|
+
if (existsSync(join(ticketsLogsRoot, STORAGE_FILENAMES.projectionJson))) {
|
|
168
|
+
return { root: ticketsLogsRoot, source: 'tickets-logs' };
|
|
169
|
+
}
|
|
170
|
+
// Priority 4: .dru-code/ convention
|
|
171
|
+
const druCodeRoot = join(cwd, '.dru-code');
|
|
172
|
+
if (existsSync(join(druCodeRoot, STORAGE_FILENAMES.projectionJson))) {
|
|
173
|
+
return { root: druCodeRoot, source: 'dru-code' };
|
|
174
|
+
}
|
|
175
|
+
// Stop at filesystem root — `path.dirname('/') === '/'` on POSIX,
|
|
176
|
+
// and on Windows `path.dirname('C:\\') === 'C:\\'`. Either way the
|
|
177
|
+
// parent === self loop guard catches it.
|
|
178
|
+
const parent = dirname(cwd);
|
|
179
|
+
if (parent === cwd) break;
|
|
180
|
+
cwd = parent;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Helper for callers that already have a fully-resolved root and want to
|
|
187
|
+
* compute file paths (tests, custom integrations). Public so consumers can
|
|
188
|
+
* mirror the layout invariant without importing internal helpers.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} root absolute or relative; resolved against cwd if relative
|
|
191
|
+
* @returns {{ root: string, eventsJsonl: string, projectionJson: string, lockFile: string }}
|
|
192
|
+
*/
|
|
193
|
+
export function pathsFromRoot(root) {
|
|
194
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
195
|
+
throw new TypeError('pathsFromRoot: root must be a non-empty string');
|
|
196
|
+
}
|
|
197
|
+
const abs = isAbsolute(root) ? root : resolve(root);
|
|
198
|
+
return { root: abs, ...buildFilePaths(abs) };
|
|
199
|
+
}
|