@smartmemory/compose 0.1.4-beta → 0.1.6-beta
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/bin/compose.js +279 -0
- package/bin/git-hooks/post-commit.template +61 -0
- package/lib/build.js +31 -3
- package/lib/changelog-writer.js +647 -0
- package/lib/completion-writer.js +465 -0
- package/lib/feature-events.js +114 -0
- package/lib/feature-writer.js +585 -0
- package/lib/idempotency.js +138 -0
- package/lib/journal-writer.js +928 -0
- package/lib/roadmap-parser.js +3 -1
- package/lib/sections.js +188 -0
- package/package.json +5 -1
- package/server/compose-mcp-tools.js +82 -0
- package/server/compose-mcp.js +273 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* idempotency.js — caller-provided idempotency keys with persistent cache.
|
|
3
|
+
*
|
|
4
|
+
* Used by the feature-management writers (COMP-MCP-FEATURE-MGMT) so that the
|
|
5
|
+
* same logical operation invoked twice with the same key returns the first
|
|
6
|
+
* result without re-mutating state.
|
|
7
|
+
*
|
|
8
|
+
* Cache file: <cwd>/.compose/data/idempotency-keys.jsonl
|
|
9
|
+
* Lock file: <cwd>/.compose/data/idempotency-keys.lock (advisory mkdir lock)
|
|
10
|
+
*
|
|
11
|
+
* Each cache row is JSON: { key, result, ts }.
|
|
12
|
+
* Cap at MAX_ENTRIES (default 1000). When the cap is exceeded, the oldest
|
|
13
|
+
* entries are dropped on the next write. No background sweep — drop happens
|
|
14
|
+
* inline so we never block on large rewrites unnecessarily.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
|
|
18
|
+
import { join, dirname } from 'path';
|
|
19
|
+
|
|
20
|
+
const MAX_ENTRIES = 1000;
|
|
21
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
22
|
+
const LOCK_RETRY_MS = 25;
|
|
23
|
+
|
|
24
|
+
function cacheFile(cwd) {
|
|
25
|
+
return join(cwd, '.compose', 'data', 'idempotency-keys.jsonl');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lockFile(cwd) {
|
|
29
|
+
return join(cwd, '.compose', 'data', 'idempotency-keys.lock');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureDir(file) {
|
|
33
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Acquire an advisory lock by creating a directory (atomic on POSIX). Caller
|
|
38
|
+
* is responsible for releasing via the returned function. Stale locks older
|
|
39
|
+
* than LOCK_TIMEOUT_MS are forcibly cleared so a crashed prior holder can't
|
|
40
|
+
* deadlock the next caller.
|
|
41
|
+
*/
|
|
42
|
+
async function acquireLock(cwd) {
|
|
43
|
+
const path = lockFile(cwd);
|
|
44
|
+
ensureDir(path);
|
|
45
|
+
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
// eslint-disable-next-line no-constant-condition
|
|
48
|
+
while (true) {
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(path);
|
|
51
|
+
return () => {
|
|
52
|
+
try { rmSync(path, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
53
|
+
};
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code !== 'EEXIST') throw err;
|
|
56
|
+
// Stale lock recovery: if older than timeout, clear and retry.
|
|
57
|
+
try {
|
|
58
|
+
const { mtimeMs } = (await import('fs')).statSync(path);
|
|
59
|
+
if (Date.now() - mtimeMs > LOCK_TIMEOUT_MS) {
|
|
60
|
+
rmSync(path, { recursive: true, force: true });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
} catch { /* stat raced; loop and retry */ }
|
|
64
|
+
|
|
65
|
+
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
|
66
|
+
throw new Error(`idempotency lock timeout after ${LOCK_TIMEOUT_MS}ms: ${path}`);
|
|
67
|
+
}
|
|
68
|
+
await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readEntries(cwd) {
|
|
74
|
+
const path = cacheFile(cwd);
|
|
75
|
+
if (!existsSync(path)) return [];
|
|
76
|
+
const text = readFileSync(path, 'utf-8');
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const line of text.split('\n')) {
|
|
79
|
+
if (!line.trim()) continue;
|
|
80
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeEntries(cwd, entries) {
|
|
86
|
+
const path = cacheFile(cwd);
|
|
87
|
+
ensureDir(path);
|
|
88
|
+
const trimmed = entries.length > MAX_ENTRIES
|
|
89
|
+
? entries.slice(entries.length - MAX_ENTRIES)
|
|
90
|
+
: entries;
|
|
91
|
+
const body = trimmed.map(e => JSON.stringify(e)).join('\n') + (trimmed.length ? '\n' : '');
|
|
92
|
+
writeFileSync(path, body);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run computeFn() at most once for a given key. If the key has been seen
|
|
97
|
+
* before, return the cached result without invoking computeFn. Caller must
|
|
98
|
+
* supply a stable key derived from the operation's intent.
|
|
99
|
+
*
|
|
100
|
+
* Intentionally async because the lock acquisition is async; computeFn may
|
|
101
|
+
* be sync or async.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} cwd
|
|
104
|
+
* @param {string} key
|
|
105
|
+
* @param {() => any | Promise<any>} computeFn
|
|
106
|
+
* @returns {Promise<{ result: any, cached: boolean }>}
|
|
107
|
+
*/
|
|
108
|
+
export async function checkOrInsert(cwd, key, computeFn) {
|
|
109
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
110
|
+
throw new Error('idempotency: key must be a non-empty string');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const release = await acquireLock(cwd);
|
|
114
|
+
try {
|
|
115
|
+
const entries = readEntries(cwd);
|
|
116
|
+
const hit = entries.find(e => e.key === key);
|
|
117
|
+
if (hit) {
|
|
118
|
+
return { result: hit.result, cached: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const result = await computeFn();
|
|
122
|
+
entries.push({ key, result, ts: new Date().toISOString() });
|
|
123
|
+
writeEntries(cwd, entries);
|
|
124
|
+
return { result, cached: false };
|
|
125
|
+
} finally {
|
|
126
|
+
release();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Test/diagnostic helper: clear the cache. Not exposed via MCP.
|
|
132
|
+
*/
|
|
133
|
+
export function _resetIdempotency(cwd) {
|
|
134
|
+
const path = cacheFile(cwd);
|
|
135
|
+
if (existsSync(path)) rmSync(path);
|
|
136
|
+
const lock = lockFile(cwd);
|
|
137
|
+
if (existsSync(lock)) rmSync(lock, { recursive: true, force: true });
|
|
138
|
+
}
|