start-command 0.13.0 → 0.15.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 +28 -231
- package/bun.lock +5 -0
- package/eslint.config.mjs +1 -1
- package/package.json +11 -6
- package/src/bin/cli.js +275 -137
- package/src/lib/args-parser.js +118 -0
- package/src/lib/execution-store.js +722 -0
- package/src/lib/isolation.js +51 -0
- package/src/lib/status-formatter.js +121 -0
- package/src/lib/version.js +143 -0
- package/test/args-parser.test.js +107 -0
- package/test/cli.test.js +11 -1
- package/test/docker-autoremove.test.js +11 -16
- package/test/execution-store.test.js +483 -0
- package/test/isolation-cleanup.test.js +11 -16
- package/test/isolation.test.js +11 -17
- package/test/public-exports.test.js +105 -0
- package/test/status-query.test.js +195 -0
- package/.github/workflows/release.yml +0 -352
- package/.husky/pre-commit +0 -1
- package/ARCHITECTURE.md +0 -297
- package/LICENSE +0 -24
- package/README.md +0 -339
- package/REQUIREMENTS.md +0 -299
- package/docs/PIPES.md +0 -243
- package/docs/USAGE.md +0 -194
- package/docs/case-studies/issue-15/README.md +0 -208
- package/docs/case-studies/issue-18/README.md +0 -343
- package/docs/case-studies/issue-18/issue-comments.json +0 -1
- package/docs/case-studies/issue-18/issue-data.json +0 -7
- package/docs/case-studies/issue-22/analysis.md +0 -547
- package/docs/case-studies/issue-22/issue-data.json +0 -12
- package/docs/case-studies/issue-25/README.md +0 -232
- package/docs/case-studies/issue-25/issue-data.json +0 -21
- package/docs/case-studies/issue-28/README.md +0 -405
- package/docs/case-studies/issue-28/issue-data.json +0 -105
- package/docs/case-studies/issue-28/raw-issue-data.md +0 -92
- package/experiments/debug-regex.js +0 -49
- package/experiments/isolation-design.md +0 -131
- package/experiments/screen-output-test.js +0 -265
- package/experiments/test-cli.sh +0 -42
- package/experiments/test-command-stream-cjs.cjs +0 -30
- package/experiments/test-command-stream-wrapper.js +0 -54
- package/experiments/test-command-stream.mjs +0 -56
- package/experiments/test-screen-attached.js +0 -126
- package/experiments/test-screen-logfile.js +0 -286
- package/experiments/test-screen-modes.js +0 -128
- package/experiments/test-screen-output.sh +0 -27
- package/experiments/test-screen-tee-debug.js +0 -237
- package/experiments/test-screen-tee-fallback.js +0 -230
- package/experiments/test-substitution.js +0 -143
- package/experiments/user-isolation-research.md +0 -83
- package/scripts/changeset-version.mjs +0 -38
- package/scripts/check-file-size.mjs +0 -103
- package/scripts/create-github-release.mjs +0 -93
- package/scripts/create-manual-changeset.mjs +0 -89
- package/scripts/format-github-release.mjs +0 -83
- package/scripts/format-release-notes.mjs +0 -219
- package/scripts/instant-version-bump.mjs +0 -121
- package/scripts/publish-to-npm.mjs +0 -129
- package/scripts/setup-npm.mjs +0 -37
- package/scripts/validate-changeset.mjs +0 -107
- package/scripts/version-and-commit.mjs +0 -237
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution Store - Dual storage for command execution records
|
|
3
|
+
*
|
|
4
|
+
* Stores command execution data in:
|
|
5
|
+
* 1. Text format (.lino files) using lino-objects-codec
|
|
6
|
+
* 2. Binary format (.links database) using clink if available
|
|
7
|
+
*
|
|
8
|
+
* Each execution record contains:
|
|
9
|
+
* - uuid: Unique identifier for the command call
|
|
10
|
+
* - pid: Process ID
|
|
11
|
+
* - status: 'executing' or 'executed'
|
|
12
|
+
* - exitCode: Return status code (null while executing)
|
|
13
|
+
* - command: The command string that was executed
|
|
14
|
+
* - logPath: Path to the log file
|
|
15
|
+
* - startTime: Timestamp when execution started
|
|
16
|
+
* - endTime: Timestamp when execution completed (null while executing)
|
|
17
|
+
* - options: Execution options (isolation mode, etc.)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const { execSync, spawnSync } = require('child_process');
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
|
|
26
|
+
// Synchronous wrapper using Bun's native ESM support
|
|
27
|
+
// This works because Bun handles ESM/CJS interop
|
|
28
|
+
function encodeSync(data) {
|
|
29
|
+
// Use synchronous require with Bun's ESM support
|
|
30
|
+
try {
|
|
31
|
+
const codec = require('lino-objects-codec');
|
|
32
|
+
return codec.encode({ obj: data });
|
|
33
|
+
} catch {
|
|
34
|
+
return JSON.stringify(data);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decodeSync(notation) {
|
|
39
|
+
try {
|
|
40
|
+
const codec = require('lino-objects-codec');
|
|
41
|
+
return codec.decode({ notation });
|
|
42
|
+
} catch {
|
|
43
|
+
return JSON.parse(notation);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Configuration
|
|
48
|
+
const DEFAULT_APP_FOLDER = path.join(os.homedir(), '.start-command');
|
|
49
|
+
const LINO_DB_FILE = 'executions.lino';
|
|
50
|
+
const LINKS_DB_FILE = 'executions.links';
|
|
51
|
+
const LOCK_FILE = 'executions.lock';
|
|
52
|
+
const LOCK_TIMEOUT_MS = 30000; // 30 second timeout for lock acquisition
|
|
53
|
+
const LOCK_STALE_MS = 60000; // Consider lock stale after 60 seconds
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Execution status enumeration
|
|
57
|
+
*/
|
|
58
|
+
const ExecutionStatus = {
|
|
59
|
+
EXECUTING: 'executing',
|
|
60
|
+
EXECUTED: 'executed',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Command Execution Record
|
|
65
|
+
*/
|
|
66
|
+
class ExecutionRecord {
|
|
67
|
+
constructor(options = {}) {
|
|
68
|
+
this.uuid = options.uuid || crypto.randomUUID();
|
|
69
|
+
this.pid = options.pid || null;
|
|
70
|
+
this.status = options.status || ExecutionStatus.EXECUTING;
|
|
71
|
+
this.exitCode = options.exitCode !== undefined ? options.exitCode : null;
|
|
72
|
+
this.command = options.command || '';
|
|
73
|
+
this.logPath = options.logPath || '';
|
|
74
|
+
this.startTime = options.startTime || new Date().toISOString();
|
|
75
|
+
this.endTime = options.endTime || null;
|
|
76
|
+
this.workingDirectory = options.workingDirectory || process.cwd();
|
|
77
|
+
this.shell = options.shell || process.env.SHELL || '/bin/sh';
|
|
78
|
+
this.platform = options.platform || process.platform;
|
|
79
|
+
this.options = options.options || {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark execution as completed
|
|
84
|
+
* @param {number} exitCode - Exit code from the process
|
|
85
|
+
*/
|
|
86
|
+
complete(exitCode) {
|
|
87
|
+
this.status = ExecutionStatus.EXECUTED;
|
|
88
|
+
this.exitCode = exitCode;
|
|
89
|
+
this.endTime = new Date().toISOString();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert to plain object for serialization
|
|
94
|
+
*/
|
|
95
|
+
toObject() {
|
|
96
|
+
return {
|
|
97
|
+
uuid: this.uuid,
|
|
98
|
+
pid: this.pid,
|
|
99
|
+
status: this.status,
|
|
100
|
+
exitCode: this.exitCode,
|
|
101
|
+
command: this.command,
|
|
102
|
+
logPath: this.logPath,
|
|
103
|
+
startTime: this.startTime,
|
|
104
|
+
endTime: this.endTime,
|
|
105
|
+
workingDirectory: this.workingDirectory,
|
|
106
|
+
shell: this.shell,
|
|
107
|
+
platform: this.platform,
|
|
108
|
+
options: this.options,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create from plain object
|
|
114
|
+
*/
|
|
115
|
+
static fromObject(obj) {
|
|
116
|
+
return new ExecutionRecord(obj);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* File-based lock manager
|
|
122
|
+
*/
|
|
123
|
+
class LockManager {
|
|
124
|
+
constructor(lockFilePath) {
|
|
125
|
+
this.lockFilePath = lockFilePath;
|
|
126
|
+
this.lockAcquired = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Acquire an exclusive lock
|
|
131
|
+
* @param {number} timeout - Maximum time to wait for lock in ms
|
|
132
|
+
* @returns {boolean} True if lock acquired
|
|
133
|
+
*/
|
|
134
|
+
acquire(timeout = LOCK_TIMEOUT_MS) {
|
|
135
|
+
const startTime = Date.now();
|
|
136
|
+
|
|
137
|
+
while (Date.now() - startTime < timeout) {
|
|
138
|
+
try {
|
|
139
|
+
// Check if existing lock is stale
|
|
140
|
+
if (fs.existsSync(this.lockFilePath)) {
|
|
141
|
+
const lockData = this.readLockFile();
|
|
142
|
+
if (lockData && this.isLockStale(lockData)) {
|
|
143
|
+
// Remove stale lock
|
|
144
|
+
fs.unlinkSync(this.lockFilePath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Try to create lock file exclusively
|
|
149
|
+
const lockData = {
|
|
150
|
+
pid: process.pid,
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
hostname: os.hostname(),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
fs.writeFileSync(this.lockFilePath, JSON.stringify(lockData), {
|
|
156
|
+
flag: 'wx', // Fail if file exists
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
this.lockAcquired = true;
|
|
160
|
+
return true;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err.code === 'EEXIST') {
|
|
163
|
+
// Lock file exists, wait and retry
|
|
164
|
+
this.sleep(100);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Release the lock
|
|
176
|
+
*/
|
|
177
|
+
release() {
|
|
178
|
+
if (this.lockAcquired) {
|
|
179
|
+
try {
|
|
180
|
+
fs.unlinkSync(this.lockFilePath);
|
|
181
|
+
} catch {
|
|
182
|
+
// Ignore errors during release
|
|
183
|
+
}
|
|
184
|
+
this.lockAcquired = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read lock file data
|
|
190
|
+
*/
|
|
191
|
+
readLockFile() {
|
|
192
|
+
try {
|
|
193
|
+
const content = fs.readFileSync(this.lockFilePath, 'utf8');
|
|
194
|
+
return JSON.parse(content);
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Check if lock is stale
|
|
202
|
+
*/
|
|
203
|
+
isLockStale(lockData) {
|
|
204
|
+
if (!lockData || !lockData.timestamp) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Check if lock is too old
|
|
209
|
+
if (Date.now() - lockData.timestamp > LOCK_STALE_MS) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check if the process that holds the lock is still running
|
|
214
|
+
if (lockData.pid && lockData.hostname === os.hostname()) {
|
|
215
|
+
try {
|
|
216
|
+
process.kill(lockData.pid, 0); // Signal 0 just checks if process exists
|
|
217
|
+
return false; // Process exists, lock is valid
|
|
218
|
+
} catch {
|
|
219
|
+
return true; // Process doesn't exist, lock is stale
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Simple sleep function
|
|
228
|
+
*/
|
|
229
|
+
sleep(ms) {
|
|
230
|
+
const end = Date.now() + ms;
|
|
231
|
+
while (Date.now() < end) {
|
|
232
|
+
// Busy wait (not ideal but works for short durations)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if clink is installed
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
function isClinkInstalled() {
|
|
242
|
+
try {
|
|
243
|
+
const result = spawnSync('clink', ['--version'], {
|
|
244
|
+
encoding: 'utf8',
|
|
245
|
+
timeout: 5000,
|
|
246
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
247
|
+
});
|
|
248
|
+
return result.status === 0;
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* ExecutionStore - Main store class for managing execution records
|
|
256
|
+
*/
|
|
257
|
+
class ExecutionStore {
|
|
258
|
+
constructor(options = {}) {
|
|
259
|
+
this.appFolder = options.appFolder || DEFAULT_APP_FOLDER;
|
|
260
|
+
this.linoDbPath = path.join(this.appFolder, LINO_DB_FILE);
|
|
261
|
+
this.linksDbPath = path.join(this.appFolder, LINKS_DB_FILE);
|
|
262
|
+
this.lockFilePath = path.join(this.appFolder, LOCK_FILE);
|
|
263
|
+
this.useLinks = options.useLinks !== false && isClinkInstalled();
|
|
264
|
+
this.verbose = options.verbose || false;
|
|
265
|
+
|
|
266
|
+
// Ensure app folder exists
|
|
267
|
+
this.ensureAppFolder();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Ensure the application folder exists
|
|
272
|
+
*/
|
|
273
|
+
ensureAppFolder() {
|
|
274
|
+
if (!fs.existsSync(this.appFolder)) {
|
|
275
|
+
fs.mkdirSync(this.appFolder, { recursive: true });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Log verbose message
|
|
281
|
+
*/
|
|
282
|
+
log(message) {
|
|
283
|
+
if (this.verbose) {
|
|
284
|
+
console.log(`[ExecutionStore] ${message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Read all execution records from lino file
|
|
290
|
+
* @returns {ExecutionRecord[]}
|
|
291
|
+
*/
|
|
292
|
+
readLinoRecords() {
|
|
293
|
+
if (!fs.existsSync(this.linoDbPath)) {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const content = fs.readFileSync(this.linoDbPath, 'utf8');
|
|
299
|
+
if (!content.trim()) {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const data = decodeSync(content);
|
|
304
|
+
if (!Array.isArray(data)) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return data.map((obj) => ExecutionRecord.fromObject(obj));
|
|
309
|
+
} catch (err) {
|
|
310
|
+
this.log(`Error reading lino records: ${err.message}`);
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Write execution records to lino file
|
|
317
|
+
* @param {ExecutionRecord[]} records
|
|
318
|
+
*/
|
|
319
|
+
writeLinoRecords(records) {
|
|
320
|
+
const data = records.map((r) => r.toObject());
|
|
321
|
+
const content = encodeSync(data);
|
|
322
|
+
fs.writeFileSync(this.linoDbPath, content, 'utf8');
|
|
323
|
+
this.log(`Wrote ${records.length} records to lino file`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Convert execution record to clink links notation format
|
|
328
|
+
* Uses string aliases for readable IDs
|
|
329
|
+
* @param {ExecutionRecord} record
|
|
330
|
+
* @returns {string}
|
|
331
|
+
*/
|
|
332
|
+
recordToLinksNotation(record) {
|
|
333
|
+
// Using clink's string alias feature for readable identifiers
|
|
334
|
+
// Format: (uuid: uuid-value) (pid: pid-value) etc.
|
|
335
|
+
const obj = record.toObject();
|
|
336
|
+
const parts = [];
|
|
337
|
+
|
|
338
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
339
|
+
if (value !== null && value !== undefined) {
|
|
340
|
+
// Escape value properly for links notation
|
|
341
|
+
const escapedValue =
|
|
342
|
+
typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
343
|
+
parts.push(`(${record.uuid}.${key}: ${key} "${escapedValue}")`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return parts.join(' ');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Build clink query for creating/updating a record
|
|
352
|
+
* @param {ExecutionRecord} record
|
|
353
|
+
* @returns {string}
|
|
354
|
+
*/
|
|
355
|
+
buildClinkCreateQuery(record) {
|
|
356
|
+
const obj = record.toObject();
|
|
357
|
+
const links = [];
|
|
358
|
+
|
|
359
|
+
// Create main record link
|
|
360
|
+
links.push(`(${record.uuid}: ExecutionRecord ${record.uuid})`);
|
|
361
|
+
|
|
362
|
+
// Create property links
|
|
363
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
364
|
+
if (value !== null && value !== undefined) {
|
|
365
|
+
const escapedValue =
|
|
366
|
+
typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
367
|
+
// Using format: (uuid.property: property "value")
|
|
368
|
+
links.push(`(${record.uuid}.${key}: ${key} "${escapedValue}")`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Format: () ((links)) - creates new links
|
|
373
|
+
return `() ((${links.join(') (')}))`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Execute clink command
|
|
378
|
+
* @param {string} query
|
|
379
|
+
* @returns {{success: boolean, output: string}}
|
|
380
|
+
*/
|
|
381
|
+
execClink(query) {
|
|
382
|
+
try {
|
|
383
|
+
const result = execSync(`clink '${query}' --db "${this.linksDbPath}"`, {
|
|
384
|
+
encoding: 'utf8',
|
|
385
|
+
timeout: 10000,
|
|
386
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
387
|
+
});
|
|
388
|
+
return { success: true, output: result };
|
|
389
|
+
} catch (err) {
|
|
390
|
+
this.log(`Clink error: ${err.message}`);
|
|
391
|
+
return { success: false, output: err.message };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Write a record to the links database using clink
|
|
397
|
+
* @param {ExecutionRecord} record
|
|
398
|
+
* @returns {boolean}
|
|
399
|
+
*/
|
|
400
|
+
writeLinksRecord(record) {
|
|
401
|
+
if (!this.useLinks) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const query = this.buildClinkCreateQuery(record);
|
|
406
|
+
const result = this.execClink(query);
|
|
407
|
+
|
|
408
|
+
if (result.success) {
|
|
409
|
+
this.log(`Wrote record ${record.uuid} to links database`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return result.success;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Delete a record from links database
|
|
417
|
+
* @param {string} uuid
|
|
418
|
+
* @returns {boolean}
|
|
419
|
+
*/
|
|
420
|
+
deleteLinksRecord(uuid) {
|
|
421
|
+
if (!this.useLinks) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Delete all links with this UUID prefix
|
|
426
|
+
const query = `(($id: ${uuid} $any)) ()`;
|
|
427
|
+
const result = this.execClink(query);
|
|
428
|
+
return result.success;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Save an execution record (creates or updates)
|
|
433
|
+
* @param {ExecutionRecord} record
|
|
434
|
+
* @returns {boolean}
|
|
435
|
+
*/
|
|
436
|
+
save(record) {
|
|
437
|
+
const lock = new LockManager(this.lockFilePath);
|
|
438
|
+
|
|
439
|
+
if (!lock.acquire()) {
|
|
440
|
+
throw new Error('Failed to acquire lock for database write');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
// Read existing records
|
|
445
|
+
const records = this.readLinoRecords();
|
|
446
|
+
|
|
447
|
+
// Find existing record index
|
|
448
|
+
const existingIndex = records.findIndex((r) => r.uuid === record.uuid);
|
|
449
|
+
|
|
450
|
+
if (existingIndex >= 0) {
|
|
451
|
+
// Update existing record
|
|
452
|
+
records[existingIndex] = record;
|
|
453
|
+
} else {
|
|
454
|
+
// Add new record
|
|
455
|
+
records.push(record);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Write to lino file
|
|
459
|
+
this.writeLinoRecords(records);
|
|
460
|
+
|
|
461
|
+
// Also write to links database if available
|
|
462
|
+
if (this.useLinks) {
|
|
463
|
+
this.writeLinksRecord(record);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return true;
|
|
467
|
+
} finally {
|
|
468
|
+
lock.release();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get an execution record by UUID
|
|
474
|
+
* @param {string} uuid
|
|
475
|
+
* @returns {ExecutionRecord|null}
|
|
476
|
+
*/
|
|
477
|
+
get(uuid) {
|
|
478
|
+
const records = this.readLinoRecords();
|
|
479
|
+
const found = records.find((r) => r.uuid === uuid);
|
|
480
|
+
return found || null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Get all execution records
|
|
485
|
+
* @returns {ExecutionRecord[]}
|
|
486
|
+
*/
|
|
487
|
+
getAll() {
|
|
488
|
+
return this.readLinoRecords();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Get records filtered by status
|
|
493
|
+
* @param {string} status
|
|
494
|
+
* @returns {ExecutionRecord[]}
|
|
495
|
+
*/
|
|
496
|
+
getByStatus(status) {
|
|
497
|
+
return this.readLinoRecords().filter((r) => r.status === status);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Get currently executing commands
|
|
502
|
+
* @returns {ExecutionRecord[]}
|
|
503
|
+
*/
|
|
504
|
+
getExecuting() {
|
|
505
|
+
return this.getByStatus(ExecutionStatus.EXECUTING);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Get recently executed commands
|
|
510
|
+
* @param {number} limit
|
|
511
|
+
* @returns {ExecutionRecord[]}
|
|
512
|
+
*/
|
|
513
|
+
getRecent(limit = 10) {
|
|
514
|
+
const records = this.readLinoRecords();
|
|
515
|
+
// Sort by startTime descending
|
|
516
|
+
records.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
|
|
517
|
+
return records.slice(0, limit);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Delete an execution record
|
|
522
|
+
* @param {string} uuid
|
|
523
|
+
* @returns {boolean}
|
|
524
|
+
*/
|
|
525
|
+
delete(uuid) {
|
|
526
|
+
const lock = new LockManager(this.lockFilePath);
|
|
527
|
+
|
|
528
|
+
if (!lock.acquire()) {
|
|
529
|
+
throw new Error('Failed to acquire lock for database write');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
const records = this.readLinoRecords();
|
|
534
|
+
const filteredRecords = records.filter((r) => r.uuid !== uuid);
|
|
535
|
+
|
|
536
|
+
if (filteredRecords.length === records.length) {
|
|
537
|
+
return false; // Record not found
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
this.writeLinoRecords(filteredRecords);
|
|
541
|
+
|
|
542
|
+
// Also delete from links database
|
|
543
|
+
if (this.useLinks) {
|
|
544
|
+
this.deleteLinksRecord(uuid);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return true;
|
|
548
|
+
} finally {
|
|
549
|
+
lock.release();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Delete all records
|
|
555
|
+
*/
|
|
556
|
+
clear() {
|
|
557
|
+
const lock = new LockManager(this.lockFilePath);
|
|
558
|
+
|
|
559
|
+
if (!lock.acquire()) {
|
|
560
|
+
throw new Error('Failed to acquire lock for database write');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
this.writeLinoRecords([]);
|
|
565
|
+
|
|
566
|
+
// Clear links database by removing the file
|
|
567
|
+
if (this.useLinks && fs.existsSync(this.linksDbPath)) {
|
|
568
|
+
fs.unlinkSync(this.linksDbPath);
|
|
569
|
+
}
|
|
570
|
+
} finally {
|
|
571
|
+
lock.release();
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Verify that both databases have consistent data
|
|
577
|
+
* @returns {{consistent: boolean, linoCount: number, linksCount: number, errors: string[]}}
|
|
578
|
+
*/
|
|
579
|
+
verifyConsistency() {
|
|
580
|
+
const result = {
|
|
581
|
+
consistent: true,
|
|
582
|
+
linoCount: 0,
|
|
583
|
+
linksCount: 0,
|
|
584
|
+
errors: [],
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// Read lino records
|
|
588
|
+
const linoRecords = this.readLinoRecords();
|
|
589
|
+
result.linoCount = linoRecords.length;
|
|
590
|
+
|
|
591
|
+
if (!this.useLinks) {
|
|
592
|
+
// If clink is not available, just report lino count
|
|
593
|
+
result.linksCount = 0;
|
|
594
|
+
result.errors.push('clink not installed - links database not available');
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Query links database for all ExecutionRecord links
|
|
599
|
+
try {
|
|
600
|
+
const queryResult = this.execClink(
|
|
601
|
+
`((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))`
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
if (queryResult.success) {
|
|
605
|
+
// Count unique UUIDs in the links output
|
|
606
|
+
const output = queryResult.output || '';
|
|
607
|
+
const uuidMatches = output.match(/ExecutionRecord\s+([a-f0-9-]{36})/gi);
|
|
608
|
+
const uniqueUuids = new Set(
|
|
609
|
+
(uuidMatches || []).map((m) =>
|
|
610
|
+
m.replace(/ExecutionRecord\s+/i, '').toLowerCase()
|
|
611
|
+
)
|
|
612
|
+
);
|
|
613
|
+
result.linksCount = uniqueUuids.size;
|
|
614
|
+
|
|
615
|
+
// Check if counts match
|
|
616
|
+
if (result.linoCount !== result.linksCount) {
|
|
617
|
+
result.consistent = false;
|
|
618
|
+
result.errors.push(
|
|
619
|
+
`Record count mismatch: lino=${result.linoCount}, links=${result.linksCount}`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Verify each lino record exists in links
|
|
624
|
+
for (const record of linoRecords) {
|
|
625
|
+
if (!uniqueUuids.has(record.uuid.toLowerCase())) {
|
|
626
|
+
result.consistent = false;
|
|
627
|
+
result.errors.push(
|
|
628
|
+
`Record ${record.uuid} missing from links database`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
result.errors.push(
|
|
634
|
+
`Failed to query links database: ${queryResult.output}`
|
|
635
|
+
);
|
|
636
|
+
result.consistent = false;
|
|
637
|
+
}
|
|
638
|
+
} catch (err) {
|
|
639
|
+
result.errors.push(`Links verification error: ${err.message}`);
|
|
640
|
+
result.consistent = false;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Sync lino records to links database (repair operation)
|
|
648
|
+
* @returns {{synced: number, errors: string[]}}
|
|
649
|
+
*/
|
|
650
|
+
syncToLinks() {
|
|
651
|
+
if (!this.useLinks) {
|
|
652
|
+
return { synced: 0, errors: ['clink not installed'] };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const lock = new LockManager(this.lockFilePath);
|
|
656
|
+
if (!lock.acquire()) {
|
|
657
|
+
throw new Error('Failed to acquire lock for sync operation');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
const records = this.readLinoRecords();
|
|
662
|
+
let synced = 0;
|
|
663
|
+
const errors = [];
|
|
664
|
+
|
|
665
|
+
for (const record of records) {
|
|
666
|
+
if (this.writeLinksRecord(record)) {
|
|
667
|
+
synced++;
|
|
668
|
+
} else {
|
|
669
|
+
errors.push(`Failed to sync record ${record.uuid}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return { synced, errors };
|
|
674
|
+
} finally {
|
|
675
|
+
lock.release();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Get database statistics
|
|
681
|
+
* @returns {object}
|
|
682
|
+
*/
|
|
683
|
+
getStats() {
|
|
684
|
+
const records = this.readLinoRecords();
|
|
685
|
+
const executing = records.filter(
|
|
686
|
+
(r) => r.status === ExecutionStatus.EXECUTING
|
|
687
|
+
).length;
|
|
688
|
+
const executed = records.filter(
|
|
689
|
+
(r) => r.status === ExecutionStatus.EXECUTED
|
|
690
|
+
).length;
|
|
691
|
+
const successful = records.filter(
|
|
692
|
+
(r) => r.status === ExecutionStatus.EXECUTED && r.exitCode === 0
|
|
693
|
+
).length;
|
|
694
|
+
const failed = records.filter(
|
|
695
|
+
(r) => r.status === ExecutionStatus.EXECUTED && r.exitCode !== 0
|
|
696
|
+
).length;
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
total: records.length,
|
|
700
|
+
executing,
|
|
701
|
+
executed,
|
|
702
|
+
successful,
|
|
703
|
+
failed,
|
|
704
|
+
clinkAvailable: this.useLinks,
|
|
705
|
+
linoDbPath: this.linoDbPath,
|
|
706
|
+
linksDbPath: this.linksDbPath,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Export everything
|
|
712
|
+
module.exports = {
|
|
713
|
+
ExecutionStore,
|
|
714
|
+
ExecutionRecord,
|
|
715
|
+
ExecutionStatus,
|
|
716
|
+
LockManager,
|
|
717
|
+
isClinkInstalled,
|
|
718
|
+
DEFAULT_APP_FOLDER,
|
|
719
|
+
LINO_DB_FILE,
|
|
720
|
+
LINKS_DB_FILE,
|
|
721
|
+
LOCK_FILE,
|
|
722
|
+
};
|