@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
package/cli/format.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting helpers for sessions-db CLI subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Supports three output styles:
|
|
5
|
+
* - `formatSessionTable` — fixed-column ASCII table for `find` (default).
|
|
6
|
+
* - `formatTree` — hub-spoke ASCII tree rooted at a stable_id (depth-capped
|
|
7
|
+
* to defend against circular parent_session_id chains).
|
|
8
|
+
* - `formatJSON` — pretty-printed JSON.stringify with stable key order.
|
|
9
|
+
*
|
|
10
|
+
* No external deps — color is pure ANSI escape codes, gated by a TTY check
|
|
11
|
+
* the CLI entry can override with NO_COLOR=1 / --no-color.
|
|
12
|
+
*
|
|
13
|
+
* The depth cap matters: P3 identity surfaces parent_candidates as hub-spoke
|
|
14
|
+
* hints, but the actual `parent_session_id` is set by `link-parent`. A user
|
|
15
|
+
* could (accidentally or maliciously) create A→B→A. We cap recursion at
|
|
16
|
+
* MAX_TREE_DEPTH and surface a `(circular reference)` marker so the operator
|
|
17
|
+
* can fix it via `link-parent --remove`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MAX_TREE_DEPTH = 32;
|
|
21
|
+
|
|
22
|
+
// ANSI escape codes (zero-dep). Disabled when NO_COLOR is set or stdout is
|
|
23
|
+
// not a TTY (caller's responsibility — pass useColor=false to bypass).
|
|
24
|
+
const ANSI = Object.freeze({
|
|
25
|
+
reset: '\x1b[0m',
|
|
26
|
+
dim: '\x1b[2m',
|
|
27
|
+
bold: '\x1b[1m',
|
|
28
|
+
red: '\x1b[31m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
yellow: '\x1b[33m',
|
|
31
|
+
blue: '\x1b[34m',
|
|
32
|
+
cyan: '\x1b[36m',
|
|
33
|
+
gray: '\x1b[90m',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function paint(text, color, useColor) {
|
|
37
|
+
if (!useColor || !color) return text;
|
|
38
|
+
return color + text + ANSI.reset;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Truncate a stable_id to the first 16 chars for display
|
|
43
|
+
* (sess_<8>-<4>... is enough for visual disambiguation).
|
|
44
|
+
*
|
|
45
|
+
* Exported so tests can verify identical truncation rules across handlers.
|
|
46
|
+
*/
|
|
47
|
+
export function truncateStableId(id) {
|
|
48
|
+
if (typeof id !== 'string') return '<invalid>';
|
|
49
|
+
if (id.length <= 22) return id;
|
|
50
|
+
return id.slice(0, 22);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Human-friendly relative time ("3 hours ago", "2 days ago", "just now").
|
|
55
|
+
*
|
|
56
|
+
* Exported because both find (table cell) and tree (state suffix) want the
|
|
57
|
+
* same relative-time vocabulary so ops staff don't see "3h" in one place and
|
|
58
|
+
* "3 hours ago" in another.
|
|
59
|
+
*
|
|
60
|
+
* @param {string|null|undefined} iso - ISO 8601 timestamp
|
|
61
|
+
* @param {number} [now=Date.now()] - injectable for deterministic tests
|
|
62
|
+
*/
|
|
63
|
+
export function relTime(iso, now = Date.now()) {
|
|
64
|
+
if (!iso || typeof iso !== 'string') return '-';
|
|
65
|
+
const t = Date.parse(iso);
|
|
66
|
+
if (!Number.isFinite(t)) return '-';
|
|
67
|
+
const deltaMs = now - t;
|
|
68
|
+
if (deltaMs < 0) return 'in the future';
|
|
69
|
+
const sec = Math.floor(deltaMs / 1000);
|
|
70
|
+
if (sec < 5) return 'just now';
|
|
71
|
+
if (sec < 60) return `${sec}s ago`;
|
|
72
|
+
const min = Math.floor(sec / 60);
|
|
73
|
+
if (min < 60) return `${min}m ago`;
|
|
74
|
+
const hr = Math.floor(min / 60);
|
|
75
|
+
if (hr < 24) return `${hr}h ago`;
|
|
76
|
+
const day = Math.floor(hr / 24);
|
|
77
|
+
if (day < 30) return `${day}d ago`;
|
|
78
|
+
const mo = Math.floor(day / 30);
|
|
79
|
+
if (mo < 12) return `${mo}mo ago`;
|
|
80
|
+
const yr = Math.floor(day / 365);
|
|
81
|
+
return `${yr}y ago`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format a list of session records as a fixed-column ASCII table.
|
|
86
|
+
*
|
|
87
|
+
* @param {Array<object>} sessions
|
|
88
|
+
* @param {{ useColor?: boolean, now?: number }} [opts]
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function formatSessionTable(sessions, opts = {}) {
|
|
92
|
+
const useColor = opts.useColor === true;
|
|
93
|
+
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
94
|
+
|
|
95
|
+
if (!Array.isArray(sessions) || sessions.length === 0) {
|
|
96
|
+
return '(no sessions matched)\n';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rows = sessions.map((s) => ({
|
|
100
|
+
stable: truncateStableId(s.stable_id || ''),
|
|
101
|
+
alias: s.alias || '-',
|
|
102
|
+
state: s.activity_state || '-',
|
|
103
|
+
outcome: s.outcome || '-',
|
|
104
|
+
last: relTime(s.last_progress_at, now),
|
|
105
|
+
branch: truncBranch(s.branch_current || s.branch_at_start),
|
|
106
|
+
cwd: truncCwd(s.cwd || s.worktree_path_observed),
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const headers = {
|
|
110
|
+
stable: 'stable_id',
|
|
111
|
+
alias: 'alias',
|
|
112
|
+
state: 'state',
|
|
113
|
+
outcome: 'outcome',
|
|
114
|
+
last: 'last_progress',
|
|
115
|
+
branch: 'branch',
|
|
116
|
+
cwd: 'cwd',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const widths = {
|
|
120
|
+
stable: Math.max(headers.stable.length, ...rows.map((r) => r.stable.length)),
|
|
121
|
+
alias: Math.max(headers.alias.length, ...rows.map((r) => r.alias.length)),
|
|
122
|
+
state: Math.max(headers.state.length, ...rows.map((r) => r.state.length)),
|
|
123
|
+
outcome: Math.max(headers.outcome.length, ...rows.map((r) => r.outcome.length)),
|
|
124
|
+
last: Math.max(headers.last.length, ...rows.map((r) => r.last.length)),
|
|
125
|
+
branch: Math.max(headers.branch.length, ...rows.map((r) => r.branch.length)),
|
|
126
|
+
cwd: Math.max(headers.cwd.length, ...rows.map((r) => r.cwd.length)),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const fmt = (r, isHeader = false) => {
|
|
130
|
+
const cells = [
|
|
131
|
+
r.stable.padEnd(widths.stable),
|
|
132
|
+
r.alias.padEnd(widths.alias),
|
|
133
|
+
paintState(r.state, useColor && !isHeader, widths.state),
|
|
134
|
+
paintOutcome(r.outcome, useColor && !isHeader, widths.outcome),
|
|
135
|
+
r.last.padEnd(widths.last),
|
|
136
|
+
r.branch.padEnd(widths.branch),
|
|
137
|
+
r.cwd.padEnd(widths.cwd),
|
|
138
|
+
];
|
|
139
|
+
return cells.join(' ').trimEnd();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const lines = [];
|
|
143
|
+
lines.push(paint(fmt(headers, true), useColor ? ANSI.bold : null, useColor));
|
|
144
|
+
for (const r of rows) lines.push(fmt(r));
|
|
145
|
+
return lines.join('\n') + '\n';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function paintState(state, useColor, width) {
|
|
149
|
+
const padded = state.padEnd(width);
|
|
150
|
+
if (!useColor) return padded;
|
|
151
|
+
if (state === 'active') return paint(padded, ANSI.green, true);
|
|
152
|
+
if (state === 'idle') return paint(padded, ANSI.yellow, true);
|
|
153
|
+
if (state === 'archived') return paint(padded, ANSI.gray, true);
|
|
154
|
+
return padded;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function paintOutcome(outcome, useColor, width) {
|
|
158
|
+
const padded = outcome.padEnd(width);
|
|
159
|
+
if (!useColor) return padded;
|
|
160
|
+
if (outcome === 'open') return paint(padded, ANSI.cyan, true);
|
|
161
|
+
if (outcome === 'done' || outcome === 'merged') return paint(padded, ANSI.green, true);
|
|
162
|
+
if (outcome === 'blocked') return paint(padded, ANSI.red, true);
|
|
163
|
+
return padded;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function truncBranch(branch) {
|
|
167
|
+
if (!branch) return '-';
|
|
168
|
+
if (branch.length <= 32) return branch;
|
|
169
|
+
return branch.slice(0, 29) + '...';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function truncCwd(cwd) {
|
|
173
|
+
if (!cwd) return '-';
|
|
174
|
+
if (cwd.length <= 40) return cwd;
|
|
175
|
+
// Keep the tail (most informative — the trailing dir reveals which
|
|
176
|
+
// worktree / project this is) and prefix with `…`.
|
|
177
|
+
return '...' + cwd.slice(-37);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Format a hub-spoke tree rooted at `rootStableId`.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} rootStableId
|
|
184
|
+
* @param {object} projection
|
|
185
|
+
* @param {{ useColor?: boolean, now?: number }} [opts]
|
|
186
|
+
* @returns {string} ASCII tree text or an error sentinel string when root
|
|
187
|
+
* does not exist (caller decides exit code).
|
|
188
|
+
*/
|
|
189
|
+
export function formatTree(rootStableId, projection, opts = {}) {
|
|
190
|
+
const useColor = opts.useColor === true;
|
|
191
|
+
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
192
|
+
|
|
193
|
+
const sessions = projection && projection.sessions ? projection.sessions : {};
|
|
194
|
+
if (!sessions[rootStableId]) {
|
|
195
|
+
return `error: stable_id not found: ${rootStableId}\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build child index: parent_session_id → [child stable_ids]
|
|
199
|
+
const children = new Map();
|
|
200
|
+
for (const [sid, s] of Object.entries(sessions)) {
|
|
201
|
+
const parent = s && typeof s.parent_session_id === 'string' ? s.parent_session_id : null;
|
|
202
|
+
if (parent && parent !== sid) {
|
|
203
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
204
|
+
children.get(parent).push(sid);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Sort children by created_at ASC for stable, deterministic output.
|
|
208
|
+
for (const arr of children.values()) {
|
|
209
|
+
arr.sort((a, b) => {
|
|
210
|
+
const ca = sessions[a] && sessions[a].created_at;
|
|
211
|
+
const cb = sessions[b] && sessions[b].created_at;
|
|
212
|
+
if (!ca && !cb) return 0;
|
|
213
|
+
if (!ca) return 1;
|
|
214
|
+
if (!cb) return -1;
|
|
215
|
+
return ca < cb ? -1 : ca > cb ? 1 : 0;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lines = [];
|
|
220
|
+
const visited = new Set();
|
|
221
|
+
|
|
222
|
+
function nodeLabel(sid) {
|
|
223
|
+
const s = sessions[sid];
|
|
224
|
+
const idShort = truncateStableId(sid);
|
|
225
|
+
const alias = s && s.alias ? ` (${s.alias})` : '';
|
|
226
|
+
const stateLabel = s
|
|
227
|
+
? `[${s.activity_state || '?'}/${s.outcome || '?'}]`
|
|
228
|
+
: '[?/?]';
|
|
229
|
+
const last = s ? ` ${relTime(s.last_progress_at, now)}` : '';
|
|
230
|
+
return `${paint(idShort, useColor ? ANSI.bold : null, useColor)}${alias} ${paint(stateLabel, useColor ? ANSI.dim : null, useColor)}${last}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function emit(sid, prefix, isLast, depth) {
|
|
234
|
+
const connector = depth === 0 ? '' : (isLast ? '└── ' : '├── ');
|
|
235
|
+
lines.push(prefix + connector + nodeLabel(sid));
|
|
236
|
+
|
|
237
|
+
if (depth >= MAX_TREE_DEPTH) {
|
|
238
|
+
lines.push(prefix + (isLast ? ' ' : '│ ') + paint('(max depth reached)', useColor ? ANSI.yellow : null, useColor));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (visited.has(sid)) {
|
|
243
|
+
lines.push(prefix + (isLast ? ' ' : '│ ') + paint('(circular reference)', useColor ? ANSI.red : null, useColor));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
visited.add(sid);
|
|
247
|
+
|
|
248
|
+
const kids = children.get(sid) || [];
|
|
249
|
+
const childPrefix = prefix + (depth === 0 ? '' : (isLast ? ' ' : '│ '));
|
|
250
|
+
for (let i = 0; i < kids.length; i++) {
|
|
251
|
+
emit(kids[i], childPrefix, i === kids.length - 1, depth + 1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
emit(rootStableId, '', true, 0);
|
|
256
|
+
return lines.join('\n') + '\n';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Format any value as JSON with stable 2-space indentation.
|
|
261
|
+
* @param {any} data
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
export function formatJSON(data) {
|
|
265
|
+
return JSON.stringify(data, null, 2) + '\n';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Decide whether to enable ANSI color: TTY + NO_COLOR not set + --no-color
|
|
270
|
+
* not passed. Exposed so the CLI entry / handlers can call it once at the
|
|
271
|
+
* start and pass the boolean down to formatters.
|
|
272
|
+
*/
|
|
273
|
+
export function shouldUseColor(streamIsTTY, env = process.env, noColorFlag = false) {
|
|
274
|
+
if (noColorFlag) return false;
|
|
275
|
+
if (env && env.NO_COLOR && env.NO_COLOR.length > 0) return false;
|
|
276
|
+
return streamIsTTY === true;
|
|
277
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sessions-db link-parent <child> <parent>` — explicitly promote a hub-spoke
|
|
3
|
+
* parent relationship (sets `parent_session_id` on child).
|
|
4
|
+
* `sessions-db link-parent <child> --remove` — clear parent (set null).
|
|
5
|
+
*
|
|
6
|
+
* Day 3 refactor: all child / parent existence checks AND multi-hop cycle
|
|
7
|
+
* detection live in `lib/operations.setParent`. This handler is a thin
|
|
8
|
+
* wrapper that maps argv → operation call → exit code, so the cycle
|
|
9
|
+
* defense exists in exactly one place.
|
|
10
|
+
*
|
|
11
|
+
* Cycle defense semantics (preserved from earlier phases):
|
|
12
|
+
* - direct: child === parent (1-cycle) — rejected
|
|
13
|
+
* - multi-hop: walk the proposed parent's ancestor chain (via the
|
|
14
|
+
* projection's parent_session_id pointers) and refuse if we ever
|
|
15
|
+
* encounter `child` — that would close the loop, e.g. A→B already
|
|
16
|
+
* exists and someone runs `link-parent B A` would form A→B→A.
|
|
17
|
+
* - bound: MAX_PARENT_CHAIN_DEPTH = 50 in operations.mjs to defend
|
|
18
|
+
* against a stale projection cycle.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { setParent } from '../lib/operations.mjs';
|
|
22
|
+
import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
|
|
23
|
+
import { renderDryRun, reportResult, reportStableIdNotFound } from './_write-helpers.mjs';
|
|
24
|
+
|
|
25
|
+
const SPEC = {
|
|
26
|
+
positional: [
|
|
27
|
+
{ name: 'child', required: true },
|
|
28
|
+
{ name: 'parent', required: false },
|
|
29
|
+
],
|
|
30
|
+
flags: {
|
|
31
|
+
'--remove': { type: 'boolean' },
|
|
32
|
+
'--dry-run': { type: 'boolean' },
|
|
33
|
+
'--json': { type: 'boolean' },
|
|
34
|
+
'--root': { type: 'string' },
|
|
35
|
+
'--quiet': { type: 'boolean' },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const HELP = formatHelp({
|
|
40
|
+
usage: 'sessions-db link-parent <child> <parent> | sessions-db link-parent <child> --remove',
|
|
41
|
+
summary: 'Promote a hub-spoke parent relationship (or clear it).',
|
|
42
|
+
flags: [
|
|
43
|
+
{ name: '--remove', desc: 'clear parent_session_id (set null)' },
|
|
44
|
+
{ name: '--dry-run', desc: 'print event but do not write' },
|
|
45
|
+
{ name: '--json', desc: 'JSON output' },
|
|
46
|
+
{ name: '--root <p>', desc: 'override storage root' },
|
|
47
|
+
],
|
|
48
|
+
examples: [
|
|
49
|
+
'sessions-db link-parent sess_child-... sess_parent-...',
|
|
50
|
+
'sessions-db link-parent sess_child-... --remove',
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export async function run(argv) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = parseArgs(argv, SPEC);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof ArgparseError) {
|
|
60
|
+
process.stderr.write(`error: ${err.message}\n\n${HELP}`);
|
|
61
|
+
process.exit(err.exitCode);
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (parsed.helpRequested) {
|
|
67
|
+
process.stdout.write(HELP);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const child = parsed.positional.child;
|
|
72
|
+
const parent = parsed.positional.parent;
|
|
73
|
+
const remove = parsed.flags['--remove'] === true;
|
|
74
|
+
const root = parsed.flags['--root'];
|
|
75
|
+
const dryRun = parsed.flags['--dry-run'] === true;
|
|
76
|
+
const json = parsed.flags['--json'] === true;
|
|
77
|
+
const quiet = parsed.flags['--quiet'] === true;
|
|
78
|
+
|
|
79
|
+
if (remove && parent !== undefined) {
|
|
80
|
+
process.stderr.write(`error: parent positional and --remove are mutually exclusive\n`);
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
if (!remove && parent === undefined) {
|
|
84
|
+
process.stderr.write(`error: provide a parent stable_id or --remove\n`);
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
if (!remove && parent === child) {
|
|
88
|
+
// Self-cycle would render as "(circular reference)" and serve no
|
|
89
|
+
// purpose. Operations.setParent rejects it too, but the historical
|
|
90
|
+
// CLI message is "cannot be the same stable_id" — preserve it.
|
|
91
|
+
process.stderr.write(`error: parent and child cannot be the same stable_id\n`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (dryRun) {
|
|
96
|
+
const payload = remove ? { parent_session_id: null } : { parent_session_id: parent };
|
|
97
|
+
renderDryRun({ op: 'parent_set', stableId: child, payload, json });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const opts = root ? { root } : {};
|
|
102
|
+
const result = remove
|
|
103
|
+
? await setParent({ childId: child, clear: true, ...opts })
|
|
104
|
+
: await setParent({ childId: child, parentId: parent, ...opts });
|
|
105
|
+
|
|
106
|
+
if (!result.ok && typeof result.error === 'string') {
|
|
107
|
+
if (result.error.startsWith('stable_id not found:')) {
|
|
108
|
+
if (!quiet) {
|
|
109
|
+
const code = reportStableIdNotFound(result.error);
|
|
110
|
+
process.exit(code);
|
|
111
|
+
}
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
if (result.error.startsWith('setParent: would create a cycle:')) {
|
|
115
|
+
if (!quiet) {
|
|
116
|
+
// Strip the `setParent: ` prefix to keep the historical CLI
|
|
117
|
+
// wording (`error: link-parent would create a cycle: ...`). The
|
|
118
|
+
// operation phrasing is `would create a cycle:` — match the test
|
|
119
|
+
// regex `/would create a cycle/` either way; we re-prefix so
|
|
120
|
+
// the operator-facing message names the CLI subcommand.
|
|
121
|
+
const tail = result.error.slice('setParent: '.length);
|
|
122
|
+
process.stderr.write(`error: link-parent ${tail}\n`);
|
|
123
|
+
}
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const code = reportResult({
|
|
129
|
+
result, op: 'parent_set', stableId: child, json, quiet,
|
|
130
|
+
extra: remove ? { cleared: true } : { parent: parent },
|
|
131
|
+
});
|
|
132
|
+
if (code !== 0) process.exit(code);
|
|
133
|
+
}
|
package/cli/link.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sessions-db link <stable_id> --task X|--project X` — link a session to a
|
|
3
|
+
* task or project (additive). Pass `--remove` to dispatch a `session_unlink`
|
|
4
|
+
* event that removes the named tasks / projects.
|
|
5
|
+
*
|
|
6
|
+
* Day 3 refactor: routes through `lib/operations.linkTask` /
|
|
7
|
+
* `lib/operations.unlinkTask` — argparse + result-to-exit only.
|
|
8
|
+
*
|
|
9
|
+
* P5 (preserved from earlier phases):
|
|
10
|
+
* - `--remove --task X` writes a `session_unlink` event whose reducer
|
|
11
|
+
* filters tasks[]/projects[] in place (set-based, idempotent).
|
|
12
|
+
* - `--remove` with no `--task` / `--project` rejects with exit 2 +
|
|
13
|
+
* "requires at least one --task or --project".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { linkTask, unlinkTask } from '../lib/operations.mjs';
|
|
17
|
+
import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
|
|
18
|
+
import { renderDryRun, reportResult, reportStableIdNotFound } from './_write-helpers.mjs';
|
|
19
|
+
|
|
20
|
+
const SPEC = {
|
|
21
|
+
positional: [{ name: 'stable_id', required: true }],
|
|
22
|
+
flags: {
|
|
23
|
+
'--task': { type: 'string', repeatable: true },
|
|
24
|
+
'--project': { type: 'string', repeatable: true },
|
|
25
|
+
'--remove': { type: 'boolean' },
|
|
26
|
+
'--dry-run': { type: 'boolean' },
|
|
27
|
+
'--json': { type: 'boolean' },
|
|
28
|
+
'--root': { type: 'string' },
|
|
29
|
+
'--quiet': { type: 'boolean' },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const HELP = formatHelp({
|
|
34
|
+
usage: 'sessions-db link <stable_id> [--task T ...] [--project P ...] [--remove]',
|
|
35
|
+
summary: 'Link / unlink a session from one or more tasks / projects.',
|
|
36
|
+
flags: [
|
|
37
|
+
{ name: '--task <id>', desc: 'task filename to link (or unlink with --remove); repeatable' },
|
|
38
|
+
{ name: '--project <id>', desc: 'project key to link (or unlink with --remove); repeatable' },
|
|
39
|
+
{ name: '--remove', desc: 'unlink instead of link — writes session_unlink event' },
|
|
40
|
+
{ name: '--dry-run', desc: 'print event but do not write' },
|
|
41
|
+
{ name: '--json', desc: 'JSON output' },
|
|
42
|
+
{ name: '--root <p>', desc: 'override storage root' },
|
|
43
|
+
],
|
|
44
|
+
examples: [
|
|
45
|
+
'sessions-db link sess_01970000-... --task feat-foo.md',
|
|
46
|
+
'sessions-db link sess_01970000-... --project proj-bar --task feat-foo.md',
|
|
47
|
+
'sessions-db link sess_01970000-... --remove --task feat-foo.md',
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function asArray(v) {
|
|
52
|
+
if (v === undefined) return [];
|
|
53
|
+
return Array.isArray(v) ? v : [v];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function run(argv) {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = parseArgs(argv, SPEC);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err instanceof ArgparseError) {
|
|
62
|
+
process.stderr.write(`error: ${err.message}\n\n${HELP}`);
|
|
63
|
+
process.exit(err.exitCode);
|
|
64
|
+
}
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (parsed.helpRequested) {
|
|
69
|
+
process.stdout.write(HELP);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const stableId = parsed.positional.stable_id;
|
|
74
|
+
const tasks = asArray(parsed.flags['--task']);
|
|
75
|
+
const projects = asArray(parsed.flags['--project']);
|
|
76
|
+
const remove = parsed.flags['--remove'] === true;
|
|
77
|
+
const root = parsed.flags['--root'];
|
|
78
|
+
const dryRun = parsed.flags['--dry-run'] === true;
|
|
79
|
+
const json = parsed.flags['--json'] === true;
|
|
80
|
+
const quiet = parsed.flags['--quiet'] === true;
|
|
81
|
+
|
|
82
|
+
if (tasks.length === 0 && projects.length === 0) {
|
|
83
|
+
// Same message regardless of remove vs add — both modes need targets.
|
|
84
|
+
if (remove) {
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`error: link --remove requires at least one --task or --project\n`,
|
|
87
|
+
);
|
|
88
|
+
} else {
|
|
89
|
+
process.stderr.write(`error: provide at least one --task or --project\n`);
|
|
90
|
+
}
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// P5: --remove writes a session_unlink event (set-based filter). Otherwise
|
|
95
|
+
// we keep the P1 session_link path (additive).
|
|
96
|
+
const op = remove ? 'session_unlink' : 'session_link';
|
|
97
|
+
|
|
98
|
+
if (dryRun) {
|
|
99
|
+
const payload = {};
|
|
100
|
+
if (tasks.length > 0) payload.tasks = tasks;
|
|
101
|
+
if (projects.length > 0) payload.projects = projects;
|
|
102
|
+
renderDryRun({ op, stableId, payload, json });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const opts = root ? { root } : {};
|
|
107
|
+
const fn = remove ? unlinkTask : linkTask;
|
|
108
|
+
const result = await fn({
|
|
109
|
+
stableId,
|
|
110
|
+
tasks: tasks.length > 0 ? tasks : undefined,
|
|
111
|
+
projects: projects.length > 0 ? projects : undefined,
|
|
112
|
+
...opts,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!result.ok && typeof result.error === 'string'
|
|
116
|
+
&& result.error.startsWith('stable_id not found:')) {
|
|
117
|
+
if (!quiet) {
|
|
118
|
+
const code = reportStableIdNotFound(result.error);
|
|
119
|
+
process.exit(code);
|
|
120
|
+
}
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const extra = {};
|
|
125
|
+
if (tasks.length > 0) extra.tasks = tasks;
|
|
126
|
+
if (projects.length > 0) extra.projects = projects;
|
|
127
|
+
if (remove) extra.removed = true;
|
|
128
|
+
const code = reportResult({
|
|
129
|
+
result, op, stableId, json, quiet, extra,
|
|
130
|
+
});
|
|
131
|
+
if (code !== 0) process.exit(code);
|
|
132
|
+
}
|
package/cli/rebuild.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sessions-db rebuild` — rebuild the projection cache from events.jsonl.
|
|
3
|
+
*
|
|
4
|
+
* Use cases:
|
|
5
|
+
* - Recover after a corrupted projection (loadProjection auto-falls back to
|
|
6
|
+
* rebuild on parse failure, but a manual rebuild gives ops visible
|
|
7
|
+
* confirmation).
|
|
8
|
+
* - After a hand-edit of events.jsonl (rare; e.g. removing a poisoned event).
|
|
9
|
+
* - During schema migration when the reducer changes meaning of an existing
|
|
10
|
+
* op.
|
|
11
|
+
*
|
|
12
|
+
* Output: human-readable summary by default, machine-readable JSON with
|
|
13
|
+
* --json. Tolerated tail-partial corruptions (interrupted writes) are
|
|
14
|
+
* surfaced as a count so ops can correlate against hook failures. Middle-
|
|
15
|
+
* line corruptions throw and exit 1.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { rebuildProjection } from '../lib/storage.mjs';
|
|
19
|
+
import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
|
|
20
|
+
import { formatJSON } from './format.mjs';
|
|
21
|
+
|
|
22
|
+
const SPEC = {
|
|
23
|
+
positional: [],
|
|
24
|
+
flags: {
|
|
25
|
+
'--json': { type: 'boolean' },
|
|
26
|
+
'--root': { type: 'string' },
|
|
27
|
+
'--quiet': { type: 'boolean' },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const HELP = formatHelp({
|
|
32
|
+
usage: 'sessions-db rebuild [--json] [--root <p>]',
|
|
33
|
+
summary: 'Rebuild the projection cache from events.jsonl (full fold).',
|
|
34
|
+
flags: [
|
|
35
|
+
{ name: '--json', desc: 'JSON output' },
|
|
36
|
+
{ name: '--root <p>', desc: 'override storage root' },
|
|
37
|
+
],
|
|
38
|
+
examples: [
|
|
39
|
+
'sessions-db rebuild',
|
|
40
|
+
'sessions-db rebuild --root /tmp/sessions-isolation-test',
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export async function run(argv) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = parseArgs(argv, SPEC);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err instanceof ArgparseError) {
|
|
50
|
+
process.stderr.write(`error: ${err.message}\n\n${HELP}`);
|
|
51
|
+
process.exit(err.exitCode);
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (parsed.helpRequested) {
|
|
57
|
+
process.stdout.write(HELP);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const root = parsed.flags['--root'];
|
|
62
|
+
const quiet = parsed.flags['--quiet'] === true;
|
|
63
|
+
const json = parsed.flags['--json'] === true;
|
|
64
|
+
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
result = await rebuildProjection(root ? { root } : {});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Middle-line corruption (or other rebuild failure) — surface and exit 1.
|
|
70
|
+
process.stderr.write(`error: rebuild failed: ${err && err.message ? err.message : String(err)}\n`);
|
|
71
|
+
if (err && Array.isArray(err.corruptions) && err.corruptions.length > 0) {
|
|
72
|
+
for (const c of err.corruptions.slice(0, 5)) {
|
|
73
|
+
process.stderr.write(` line ${c.lineNumber}: ${c.error}\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (quiet) return;
|
|
80
|
+
|
|
81
|
+
if (json) {
|
|
82
|
+
process.stdout.write(formatJSON({ ok: true, ...result }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// P5: surface toleratedCorruptions on a SECOND line prefixed with "warning:"
|
|
87
|
+
// so log scrapers can grep for `^warning:` (or the parent token) without
|
|
88
|
+
// reading past the "ok:" header. Format aligned with the P5 ticket §5.
|
|
89
|
+
process.stdout.write(
|
|
90
|
+
`ok: rebuilt projection — ${result.sessionCount} session${result.sessionCount === 1 ? '' : 's'}, ` +
|
|
91
|
+
`${result.eventCount} event${result.eventCount === 1 ? '' : 's'}\n`,
|
|
92
|
+
);
|
|
93
|
+
if (result.toleratedCorruptions > 0) {
|
|
94
|
+
process.stdout.write(
|
|
95
|
+
` (warning: ${result.toleratedCorruptions} tail-partial event line${result.toleratedCorruptions === 1 ? '' : 's'} tolerated)\n`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|