wendkeep 0.80.2 → 0.85.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 +103 -0
- package/README.en.md +28 -11
- package/README.md +28 -11
- package/docs/en/commands/capabilities.md +82 -0
- package/docs/en/commands/getting-started.md +3 -1
- package/docs/en/commands/mcp.md +99 -0
- package/docs/en/commands/portable.md +88 -0
- package/docs/en/commands/sync-protocol.md +58 -0
- package/docs/en/commands/tdd.md +96 -0
- package/docs/en/commands/verify.md +5 -0
- package/docs/pt-BR/commands/capabilities.md +82 -0
- package/docs/pt-BR/commands/getting-started.md +3 -2
- package/docs/pt-BR/commands/mcp.md +99 -0
- package/docs/pt-BR/commands/portable.md +87 -0
- package/docs/pt-BR/commands/sync-protocol.md +58 -0
- package/docs/pt-BR/commands/tdd.md +96 -0
- package/docs/pt-BR/commands/verify.md +5 -0
- package/hooks/active-context-store.mjs +2 -0
- package/hooks/change-core.mjs +5 -0
- package/hooks/project-scope.mjs +2 -1
- package/hooks/session-ensure.mjs +23 -7
- package/hooks/session-start.mjs +20 -5
- package/package.json +3 -3
- package/packages/cli/src/index.mjs +42 -2
- package/packages/harness/src/sensors-core.mjs +16 -3
- package/packages/integrations/src/capabilities.mjs +220 -0
- package/packages/integrations/src/index.mjs +1 -0
- package/packages/mcp/src/audit.mjs +49 -0
- package/packages/mcp/src/cli.mjs +78 -0
- package/packages/mcp/src/config.mjs +22 -1
- package/packages/mcp/src/effects.mjs +115 -0
- package/packages/mcp/src/executor.mjs +354 -0
- package/packages/mcp/src/index.mjs +7 -0
- package/packages/mcp/src/server.mjs +342 -0
- package/packages/mcp/src/stdio.mjs +38 -0
- package/packages/mcp/src/sync.mjs +56 -0
- package/packages/pi/package.json +2 -1
- package/packages/pi/src/index.mjs +29 -0
- package/schema/handoff-contract-v1.schema.json +4 -0
- package/schema/host-capability-manifest-v1.schema.json +46 -0
- package/schema/host-coverage-v1.schema.json +55 -0
- package/schema/mcp-effect-manifest-v1.schema.json +36 -0
- package/schema/mcp-tool-input-v1.schema.json +32 -0
- package/schema/mcp-tool-result-v1.schema.json +22 -0
- package/schema/portable-active-work-v1.schema.json +38 -0
- package/schema/portable-state-v1.schema.json +36 -0
- package/schema/sync-event-v1.schema.json +25 -0
- package/schema/sync-private-envelope-v1.schema.json +16 -0
- package/schema/sync-state-v1.schema.json +18 -0
- package/schema/task-contract-v1.schema.json +2 -0
- package/schema/tdd-attestation-v1.schema.json +39 -0
- package/schema/wendkeep.evidence-envelope-v2.schema.json +17 -0
- package/schema/wendkeep.sensors.schema.json +19 -0
- package/src/active-context-runtime.mjs +1 -0
- package/src/capabilities.mjs +50 -0
- package/src/doctor.mjs +28 -0
- package/src/evidence-envelope.mjs +12 -6
- package/src/host-capabilities.mjs +34 -0
- package/src/init.mjs +3 -3
- package/src/mcp.mjs +7 -0
- package/src/observer-snapshot.mjs +25 -0
- package/src/portable.mjs +558 -0
- package/src/skills-seed.mjs +26 -0
- package/src/sync-adapters.mjs +188 -0
- package/src/sync-outbox.mjs +155 -0
- package/src/sync-protocol-cli.mjs +277 -0
- package/src/sync-protocol.mjs +368 -0
- package/src/sync.mjs +8 -0
- package/src/task-contracts.mjs +67 -2
- package/src/task.mjs +5 -1
- package/src/tdd-attestation-store.mjs +98 -0
- package/src/tdd-attestation.mjs +254 -0
- package/src/tdd.mjs +198 -0
- package/src/vault-readme.mjs +4 -4
- package/src/verify.mjs +24 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
applySyncEvent, canonicalSyncJson, createSyncState, resolveSyncConflict, validateSyncEvent,
|
|
8
|
+
} from './sync-protocol.mjs';
|
|
9
|
+
|
|
10
|
+
function adapterError(code, message) {
|
|
11
|
+
return Object.assign(new Error(message), { code });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function atomicJson(path, value) {
|
|
15
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
16
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
17
|
+
try {
|
|
18
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
19
|
+
renameSync(temporary, path);
|
|
20
|
+
} finally {
|
|
21
|
+
if (existsSync(temporary)) rmSync(temporary, { force: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseLedger(path) {
|
|
26
|
+
if (!existsSync(path)) return [];
|
|
27
|
+
return readFileSync(path, 'utf8').split(/\r?\n/).filter(Boolean).map((line, index) => {
|
|
28
|
+
try { return JSON.parse(line); }
|
|
29
|
+
catch { throw adapterError('WENDKEEP_SYNC_BACKEND_CORRUPT', `backend event line ${index + 1} is corrupt`); }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readState(path, projectId) {
|
|
34
|
+
if (!existsSync(path)) return createSyncState(projectId);
|
|
35
|
+
try {
|
|
36
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
if (state?.schema_version !== 1 || state?.project_id !== projectId) throw new Error('state identity mismatch');
|
|
38
|
+
return state;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw adapterError('WENDKEEP_SYNC_BACKEND_CORRUPT', `backend state is corrupt: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sleep(milliseconds) {
|
|
45
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function withLock(path, operation, { timeoutMs = 5000, staleMs = 30000 } = {}) {
|
|
49
|
+
const started = Date.now();
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
51
|
+
while (true) {
|
|
52
|
+
try { mkdirSync(path); break; }
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
55
|
+
try {
|
|
56
|
+
if (Date.now() - statSync(path).mtimeMs > staleMs) {
|
|
57
|
+
rmSync(path, { recursive: true, force: true });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
} catch (inspectError) {
|
|
61
|
+
if (inspectError?.code !== 'ENOENT') throw inspectError;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (Date.now() - started >= timeoutMs) throw adapterError('WENDKEEP_SYNC_BACKEND_BUSY', 'backend lock is busy');
|
|
65
|
+
sleep(20);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try { return operation(); }
|
|
69
|
+
finally { rmSync(path, { recursive: true, force: true }); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createFilesystemSyncAdapter(rootPath, { available = true } = {}) {
|
|
73
|
+
const root = resolve(rootPath);
|
|
74
|
+
const statePath = join(root, 'STATE.json');
|
|
75
|
+
const eventsPath = join(root, 'EVENTS.jsonl');
|
|
76
|
+
const lockPath = join(root, '.sync.lock');
|
|
77
|
+
const assertAvailable = () => {
|
|
78
|
+
if (!available) throw adapterError('WENDKEEP_SYNC_BACKEND_UNAVAILABLE', 'filesystem backend is unavailable');
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
kind: 'filesystem',
|
|
82
|
+
id: `filesystem:${root}`,
|
|
83
|
+
async push(event) {
|
|
84
|
+
assertAvailable();
|
|
85
|
+
validateSyncEvent(event);
|
|
86
|
+
return withLock(lockPath, () => {
|
|
87
|
+
const events = parseLedger(eventsPath);
|
|
88
|
+
const state = readState(statePath, event.project_id);
|
|
89
|
+
const result = applySyncEvent(state, event);
|
|
90
|
+
if (!events.some((item) => item.event_id === event.event_id)) {
|
|
91
|
+
mkdirSync(root, { recursive: true });
|
|
92
|
+
const previous = existsSync(eventsPath) ? readFileSync(eventsPath, 'utf8') : '';
|
|
93
|
+
writeFileSync(eventsPath, `${previous}${canonicalSyncJson(event)}\n`, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
atomicJson(statePath, state);
|
|
96
|
+
return result;
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
async pull({ cursor = 0, projectId = '' } = {}) {
|
|
100
|
+
assertAvailable();
|
|
101
|
+
const offset = Number(cursor);
|
|
102
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw adapterError('WENDKEEP_SYNC_CURSOR_INVALID', 'cursor is invalid');
|
|
103
|
+
const events = parseLedger(eventsPath);
|
|
104
|
+
const inferredProject = projectId || events[0]?.project_id || '';
|
|
105
|
+
const state = inferredProject ? readState(statePath, inferredProject) : null;
|
|
106
|
+
return {
|
|
107
|
+
schema_version: 1,
|
|
108
|
+
cursor: events.length,
|
|
109
|
+
events: events.slice(offset),
|
|
110
|
+
state,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
async resolve(options = {}) {
|
|
114
|
+
assertAvailable();
|
|
115
|
+
return withLock(lockPath, () => {
|
|
116
|
+
const state = readState(statePath, options.projectId);
|
|
117
|
+
const result = resolveSyncConflict(state, options);
|
|
118
|
+
const previous = existsSync(eventsPath) ? readFileSync(eventsPath, 'utf8') : '';
|
|
119
|
+
writeFileSync(eventsPath, `${previous}${canonicalSyncJson(result.event)}\n`, 'utf8');
|
|
120
|
+
atomicJson(statePath, state);
|
|
121
|
+
return result;
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function httpUrl(base, suffix) {
|
|
128
|
+
let parsed;
|
|
129
|
+
try { parsed = new URL(base); } catch { throw adapterError('WENDKEEP_SYNC_HTTP_URL_INVALID', 'HTTP backend URL is invalid'); }
|
|
130
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) throw adapterError('WENDKEEP_SYNC_HTTP_URL_INVALID', 'HTTP backend must use HTTP(S)');
|
|
131
|
+
return `${parsed.href.replace(/\/$/, '')}/${suffix}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createHttpSyncAdapter({ url, fetchImpl = globalThis.fetch, headers = {} } = {}) {
|
|
135
|
+
if (typeof fetchImpl !== 'function') throw adapterError('WENDKEEP_SYNC_HTTP_UNAVAILABLE', 'fetch is unavailable');
|
|
136
|
+
const request = async (target, init) => {
|
|
137
|
+
let response;
|
|
138
|
+
try { response = await fetchImpl(target, init); }
|
|
139
|
+
catch { throw adapterError('WENDKEEP_SYNC_BACKEND_UNAVAILABLE', 'HTTP backend is unavailable'); }
|
|
140
|
+
if (!response?.ok) throw adapterError('WENDKEEP_SYNC_HTTP_FAILED', `HTTP backend returned ${response?.status || 'error'}`);
|
|
141
|
+
return response.json();
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
kind: 'http',
|
|
145
|
+
id: `http:${syncHost(url)}`,
|
|
146
|
+
async push(event) {
|
|
147
|
+
validateSyncEvent(event);
|
|
148
|
+
return request(httpUrl(url, 'events'), {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
151
|
+
body: canonicalSyncJson(event),
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
async pull({ cursor = 0, projectId } = {}) {
|
|
155
|
+
const query = new URLSearchParams({ project_id: String(projectId || ''), cursor: String(cursor) });
|
|
156
|
+
return request(`${httpUrl(url, 'events')}?${query}`, { method: 'GET', headers: { ...headers } });
|
|
157
|
+
},
|
|
158
|
+
async resolve(options = {}) {
|
|
159
|
+
return request(httpUrl(url, 'conflicts/resolve'), {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
162
|
+
body: canonicalSyncJson(options),
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function syncHost(value) {
|
|
169
|
+
try { return new URL(value).host; } catch { return 'invalid'; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function pushSyncEvents({ adapter, events = [], onAcknowledged = async () => {} } = {}) {
|
|
173
|
+
if (!adapter?.push) throw adapterError('WENDKEEP_SYNC_ADAPTER_INVALID', 'sync adapter cannot push');
|
|
174
|
+
const results = [];
|
|
175
|
+
for (const event of events) {
|
|
176
|
+
const result = await adapter.push(event);
|
|
177
|
+
results.push(result);
|
|
178
|
+
if (['applied', 'duplicate', 'conflict', 'pending'].includes(result.status)) {
|
|
179
|
+
await onAcknowledged(event, result);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return results;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function pullSyncEvents({ adapter, cursor = 0, projectId = '' } = {}) {
|
|
186
|
+
if (!adapter?.pull) throw adapterError('WENDKEEP_SYNC_ADAPTER_INVALID', 'sync adapter cannot pull');
|
|
187
|
+
return adapter.pull({ cursor, projectId });
|
|
188
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
assertVaultPathSafe,
|
|
6
|
+
mkdirVaultPath,
|
|
7
|
+
VAULT_LOCK_BUSY,
|
|
8
|
+
withVaultPathLock,
|
|
9
|
+
writeVaultFileAtomic,
|
|
10
|
+
} from '../packages/vault/src/vault-path-safety.mjs';
|
|
11
|
+
import { canonicalSyncJson, createSyncState, validateSyncEvent } from './sync-protocol.mjs';
|
|
12
|
+
|
|
13
|
+
const RUNTIME = ['.brain', 'runtime', 'sync'];
|
|
14
|
+
const OUTBOX = 'OUTBOX.jsonl';
|
|
15
|
+
const ACKS = 'ACKS.jsonl';
|
|
16
|
+
const LOCAL_STATE = 'LOCAL_STATE.json';
|
|
17
|
+
const CURSOR = 'CURSOR.json';
|
|
18
|
+
|
|
19
|
+
function outboxError(code, message) {
|
|
20
|
+
return Object.assign(new Error(message), { code });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function syncRuntimePaths(vaultBase) {
|
|
24
|
+
const directory = join(vaultBase, ...RUNTIME);
|
|
25
|
+
return {
|
|
26
|
+
directory, outbox: join(directory, OUTBOX), acks: join(directory, ACKS),
|
|
27
|
+
state: join(directory, LOCAL_STATE), cursor: join(directory, CURSOR),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseLines(bytes, label) {
|
|
32
|
+
const rows = [];
|
|
33
|
+
for (const [index, line] of String(bytes || '').split(/\r?\n/).entries()) {
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
try { rows.push(JSON.parse(line)); }
|
|
36
|
+
catch { throw outboxError('WENDKEEP_SYNC_OUTBOX_CORRUPT', `${label} line ${index + 1} is invalid JSON`); }
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeRead(vaultBase, path, label) {
|
|
42
|
+
if (!existsSync(path)) return '';
|
|
43
|
+
let checked;
|
|
44
|
+
try { checked = assertVaultPathSafe(vaultBase, path, { expectedType: 'file', label }); }
|
|
45
|
+
catch (error) { throw outboxError('WENDKEEP_SYNC_OUTBOX_UNSAFE', error.message); }
|
|
46
|
+
return checked.exists ? readFileSync(checked.target, 'utf8') : '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appendUnique(vaultBase, path, record, idField) {
|
|
50
|
+
const { directory } = syncRuntimePaths(vaultBase);
|
|
51
|
+
mkdirVaultPath(vaultBase, directory, { label: 'sync runtime directory' });
|
|
52
|
+
const outcome = withVaultPathLock(vaultBase, path, () => {
|
|
53
|
+
const previous = safeRead(vaultBase, path, 'sync append-only ledger');
|
|
54
|
+
const rows = parseLines(previous, 'sync ledger');
|
|
55
|
+
if (rows.some((item) => item?.[idField] === record[idField])) return { created: false, record };
|
|
56
|
+
writeVaultFileAtomic(vaultBase, path, `${previous}${canonicalSyncJson(record)}\n`, 'utf8', {
|
|
57
|
+
label: 'sync append-only ledger',
|
|
58
|
+
});
|
|
59
|
+
return { created: true, record };
|
|
60
|
+
}, { code: 'WENDKEEP_SYNC_OUTBOX_BUSY' });
|
|
61
|
+
if (outcome === VAULT_LOCK_BUSY) throw outboxError('WENDKEEP_SYNC_OUTBOX_BUSY', 'sync ledger is busy');
|
|
62
|
+
return outcome;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function enqueueSyncEvent(vaultBase, event) {
|
|
66
|
+
validateSyncEvent(event);
|
|
67
|
+
return appendUnique(vaultBase, syncRuntimePaths(vaultBase).outbox, event, 'event_id');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ackSyncEvent(vaultBase, eventId, {
|
|
71
|
+
observedAt = new Date().toISOString(), backend = '',
|
|
72
|
+
} = {}) {
|
|
73
|
+
const id = String(eventId || '').trim();
|
|
74
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw outboxError('WENDKEEP_SYNC_ACK_INVALID', 'event id is invalid');
|
|
75
|
+
const record = {
|
|
76
|
+
schema_version: 1,
|
|
77
|
+
event_id: id,
|
|
78
|
+
acknowledged_at: new Date(observedAt).toISOString(),
|
|
79
|
+
backend: String(backend || '').slice(0, 120),
|
|
80
|
+
};
|
|
81
|
+
return appendUnique(vaultBase, syncRuntimePaths(vaultBase).acks, record, 'event_id');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function readSyncOutbox(vaultBase) {
|
|
85
|
+
const paths = syncRuntimePaths(vaultBase);
|
|
86
|
+
const events = parseLines(safeRead(vaultBase, paths.outbox, 'sync outbox'), 'OUTBOX.jsonl');
|
|
87
|
+
for (const event of events) validateSyncEvent(event);
|
|
88
|
+
const acks = parseLines(safeRead(vaultBase, paths.acks, 'sync acknowledgements'), 'ACKS.jsonl');
|
|
89
|
+
const acknowledged = new Set(acks.map((item) => item.event_id));
|
|
90
|
+
return { events, acks, pending: events.filter((event) => !acknowledged.has(event.event_id)) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readPendingSyncEvents(vaultBase) {
|
|
94
|
+
return readSyncOutbox(vaultBase).pending;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function inspectSyncOutbox(vaultBase) {
|
|
98
|
+
const paths = syncRuntimePaths(vaultBase);
|
|
99
|
+
if (!existsSync(paths.directory)) return { status: 'disabled', events: 0, pending: 0, acknowledged: 0 };
|
|
100
|
+
try {
|
|
101
|
+
if (lstatSync(paths.directory).isSymbolicLink()) throw outboxError('WENDKEEP_SYNC_OUTBOX_UNSAFE', 'sync runtime is a symlink');
|
|
102
|
+
const state = readSyncOutbox(vaultBase);
|
|
103
|
+
return {
|
|
104
|
+
status: state.pending.length ? 'pending' : 'healthy',
|
|
105
|
+
events: state.events.length,
|
|
106
|
+
pending: state.pending.length,
|
|
107
|
+
acknowledged: state.acks.length,
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { status: 'corrupt', events: 0, pending: 0, acknowledged: 0, code: error.code || 'WENDKEEP_SYNC_OUTBOX_CORRUPT' };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function readLocalSyncState(vaultBase, projectId) {
|
|
115
|
+
const path = syncRuntimePaths(vaultBase).state;
|
|
116
|
+
if (!existsSync(path)) return createSyncState(projectId);
|
|
117
|
+
try {
|
|
118
|
+
const state = JSON.parse(safeRead(vaultBase, path, 'local sync state'));
|
|
119
|
+
if (state?.schema_version !== 1 || state?.project_id !== projectId) throw new Error('identity mismatch');
|
|
120
|
+
return state;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw outboxError('WENDKEEP_SYNC_STATE_CORRUPT', `local sync state is corrupt: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeRuntimeJson(vaultBase, path, value, label) {
|
|
127
|
+
const { directory } = syncRuntimePaths(vaultBase);
|
|
128
|
+
mkdirVaultPath(vaultBase, directory, { label: 'sync runtime directory' });
|
|
129
|
+
const outcome = withVaultPathLock(vaultBase, path, () => {
|
|
130
|
+
writeVaultFileAtomic(vaultBase, path, `${JSON.stringify(value, null, 2)}\n`, 'utf8', { label });
|
|
131
|
+
return value;
|
|
132
|
+
}, { code: 'WENDKEEP_SYNC_OUTBOX_BUSY' });
|
|
133
|
+
if (outcome === VAULT_LOCK_BUSY) throw outboxError('WENDKEEP_SYNC_OUTBOX_BUSY', 'sync runtime is busy');
|
|
134
|
+
return outcome;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function writeLocalSyncState(vaultBase, state) {
|
|
138
|
+
return writeRuntimeJson(vaultBase, syncRuntimePaths(vaultBase).state, state, 'local sync state');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function readSyncCursor(vaultBase) {
|
|
142
|
+
const path = syncRuntimePaths(vaultBase).cursor;
|
|
143
|
+
if (!existsSync(path)) return 0;
|
|
144
|
+
try {
|
|
145
|
+
const value = JSON.parse(safeRead(vaultBase, path, 'sync cursor'));
|
|
146
|
+
return Number.isSafeInteger(value?.cursor) && value.cursor >= 0 ? value.cursor : 0;
|
|
147
|
+
} catch { throw outboxError('WENDKEEP_SYNC_CURSOR_CORRUPT', 'local sync cursor is corrupt'); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function writeSyncCursor(vaultBase, cursor) {
|
|
151
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) throw outboxError('WENDKEEP_SYNC_CURSOR_INVALID', 'cursor is invalid');
|
|
152
|
+
return writeRuntimeJson(vaultBase, syncRuntimePaths(vaultBase).cursor, {
|
|
153
|
+
schema_version: 1, cursor,
|
|
154
|
+
}, 'sync cursor');
|
|
155
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { resolveProjectVault } from './project-vault.mjs';
|
|
5
|
+
import { importPortableState } from './portable.mjs';
|
|
6
|
+
import {
|
|
7
|
+
createFilesystemSyncAdapter, createHttpSyncAdapter, pullSyncEvents, pushSyncEvents,
|
|
8
|
+
} from './sync-adapters.mjs';
|
|
9
|
+
import {
|
|
10
|
+
applySyncEvent, canonicalRecordKey, createSyncEvent, syncSha256,
|
|
11
|
+
} from './sync-protocol.mjs';
|
|
12
|
+
import {
|
|
13
|
+
ackSyncEvent, enqueueSyncEvent, inspectSyncOutbox, readLocalSyncState,
|
|
14
|
+
readPendingSyncEvents, readSyncCursor, writeLocalSyncState, writeSyncCursor,
|
|
15
|
+
} from './sync-outbox.mjs';
|
|
16
|
+
|
|
17
|
+
const SUBCOMMANDS = new Set(['status', 'push', 'pull', 'conflicts', 'resolve']);
|
|
18
|
+
|
|
19
|
+
function cliError(code, message) {
|
|
20
|
+
return Object.assign(new Error(message), { code });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function option(argv, name) {
|
|
24
|
+
const index = argv.indexOf(name);
|
|
25
|
+
if (index >= 0) return argv[index + 1] || '';
|
|
26
|
+
return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requiredOption(argv, name) {
|
|
30
|
+
const value = option(argv, name);
|
|
31
|
+
if (!value || value.startsWith('--')) throw cliError('WENDKEEP_SYNC_ARGUMENT_REQUIRED', `${name} is required`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPortable(path) {
|
|
36
|
+
if (!existsSync(path)) throw cliError('WENDKEEP_SYNC_PORTABLE_MISSING', `portable state not found: ${path}`);
|
|
37
|
+
try {
|
|
38
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
39
|
+
if (state?.schema_version !== 1 || state?.kind !== 'wendkeep-portable-state'
|
|
40
|
+
|| !Array.isArray(state.artifacts) || !Array.isArray(state.active_work)
|
|
41
|
+
|| !state.project_id || !state.repository_id || !state.authored_sha256) throw new Error('schema mismatch');
|
|
42
|
+
return state;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw cliError('WENDKEEP_SYNC_PORTABLE_INVALID', `portable state is invalid: ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function branchForArtifact(state, path) {
|
|
49
|
+
const match = String(path).match(/^08-(?:Mudanças|Changes)\/([^/]+)\//);
|
|
50
|
+
if (!match) return '';
|
|
51
|
+
return state.active_work.find((item) => item.change_slug === match[1])?.branch || '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function desiredRecords(state) {
|
|
55
|
+
const records = [{
|
|
56
|
+
namespace: 'portable-manifest', key: 'state', scope: 'project', branch: '',
|
|
57
|
+
payload: {
|
|
58
|
+
kind: 'manifest', project_id: state.project_id, repository_id: state.repository_id,
|
|
59
|
+
authored_sha256: state.authored_sha256,
|
|
60
|
+
},
|
|
61
|
+
}];
|
|
62
|
+
for (const artifact of state.artifacts) {
|
|
63
|
+
const branch = branchForArtifact(state, artifact.path);
|
|
64
|
+
records.push({
|
|
65
|
+
namespace: 'authored', key: artifact.path, scope: branch ? 'branch' : 'project', branch,
|
|
66
|
+
payload: { kind: 'artifact', value: artifact },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const activeWork of state.active_work) records.push({
|
|
70
|
+
namespace: 'active-work', key: activeWork.active_work_id, scope: 'branch', branch: activeWork.branch,
|
|
71
|
+
payload: { kind: 'active-work', value: activeWork },
|
|
72
|
+
});
|
|
73
|
+
return records;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function portableStateToSyncEvents(state, localState, {
|
|
77
|
+
actorId, deviceId, observedAt = new Date().toISOString(),
|
|
78
|
+
} = {}) {
|
|
79
|
+
const events = [];
|
|
80
|
+
for (const desired of desiredRecords(state)) {
|
|
81
|
+
const recordKey = canonicalRecordKey({
|
|
82
|
+
projectId: state.project_id,
|
|
83
|
+
repositoryId: state.repository_id,
|
|
84
|
+
namespace: desired.namespace,
|
|
85
|
+
key: desired.key,
|
|
86
|
+
branch: desired.branch,
|
|
87
|
+
scope: desired.scope,
|
|
88
|
+
});
|
|
89
|
+
const current = localState.records[recordKey];
|
|
90
|
+
if (current?.content_hash === syncSha256(desired.payload) && !current.tombstone) continue;
|
|
91
|
+
const baseRevision = Number(current?.revision || 0);
|
|
92
|
+
events.push(createSyncEvent({
|
|
93
|
+
projectId: state.project_id,
|
|
94
|
+
recordKey,
|
|
95
|
+
revision: baseRevision + 1,
|
|
96
|
+
baseRevision,
|
|
97
|
+
payload: desired.payload,
|
|
98
|
+
causalParentIds: current?.event_id ? [current.event_id] : [],
|
|
99
|
+
actorId,
|
|
100
|
+
deviceId,
|
|
101
|
+
observedAt,
|
|
102
|
+
operation: 'put',
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
return events;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function syncStateToPortableState(state) {
|
|
109
|
+
const open = Object.values(state.conflicts || {}).filter((item) => item.status === 'open');
|
|
110
|
+
if (open.length) throw cliError('WENDKEEP_SYNC_CONFLICT_OPEN', `${open.length} conflict(s) require explicit resolution`);
|
|
111
|
+
let manifest = null;
|
|
112
|
+
const artifacts = [];
|
|
113
|
+
const active_work = [];
|
|
114
|
+
for (const record of Object.values(state.records || {})) {
|
|
115
|
+
if (record.tombstone || record.conflicted) continue;
|
|
116
|
+
if (record.payload?.kind === 'manifest') manifest = record.payload;
|
|
117
|
+
else if (record.payload?.kind === 'artifact') artifacts.push(record.payload.value);
|
|
118
|
+
else if (record.payload?.kind === 'active-work') active_work.push(record.payload.value);
|
|
119
|
+
}
|
|
120
|
+
if (!manifest) throw cliError('WENDKEEP_SYNC_MANIFEST_MISSING', 'remote state has no portable manifest');
|
|
121
|
+
artifacts.sort((left, right) => left.path.localeCompare(right.path));
|
|
122
|
+
active_work.sort((left, right) => left.active_work_id.localeCompare(right.active_work_id));
|
|
123
|
+
return {
|
|
124
|
+
schema_version: 1,
|
|
125
|
+
kind: 'wendkeep-portable-state',
|
|
126
|
+
project_id: manifest.project_id,
|
|
127
|
+
repository_id: manifest.repository_id,
|
|
128
|
+
authored_sha256: manifest.authored_sha256,
|
|
129
|
+
artifacts,
|
|
130
|
+
active_work,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function adapterFrom(argv, projectRoot) {
|
|
135
|
+
const remote = option(argv, '--remote');
|
|
136
|
+
const url = option(argv, '--url');
|
|
137
|
+
if (remote && url) throw cliError('WENDKEEP_SYNC_BACKEND_AMBIGUOUS', 'choose --remote or --url');
|
|
138
|
+
if (remote) {
|
|
139
|
+
const path = isAbsolute(remote) ? resolve(remote) : resolve(projectRoot, remote);
|
|
140
|
+
return createFilesystemSyncAdapter(path);
|
|
141
|
+
}
|
|
142
|
+
if (url) {
|
|
143
|
+
const tokenEnv = option(argv, '--token-env');
|
|
144
|
+
const token = tokenEnv ? process.env[tokenEnv] : '';
|
|
145
|
+
return createHttpSyncAdapter({
|
|
146
|
+
url,
|
|
147
|
+
...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
throw cliError('WENDKEEP_SYNC_BACKEND_REQUIRED', '--remote <path> or --url <https-url> is required');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function runtime(argv) {
|
|
154
|
+
const projectRoot = resolve(option(argv, '--project') || process.cwd());
|
|
155
|
+
const vaultBase = resolveProjectVault({
|
|
156
|
+
startDir: projectRoot,
|
|
157
|
+
explicitVault: option(argv, '--vault'),
|
|
158
|
+
validateIdentity: !option(argv, '--vault'),
|
|
159
|
+
}).base;
|
|
160
|
+
let projectId;
|
|
161
|
+
try { projectId = JSON.parse(readFileSync(join(vaultBase, '.brain', 'PROJECT.json'), 'utf8')).projectId; }
|
|
162
|
+
catch { throw cliError('WENDKEEP_SYNC_IDENTITY_UNAVAILABLE', 'PROJECT.json is unavailable'); }
|
|
163
|
+
return { projectRoot, vaultBase, projectId };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function openConflicts(state) {
|
|
167
|
+
return Object.values(state.conflicts || {}).filter((item) => item.status === 'open');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const SYNC_PROTOCOL_HELP = `wendkeep sync <status|push|pull|conflicts|resolve> [options]
|
|
171
|
+
|
|
172
|
+
--remote <path> filesystem backend (reference/local testing)
|
|
173
|
+
--url <https-url> HTTP backend
|
|
174
|
+
--token-env <name> read HTTP bearer token from an environment variable
|
|
175
|
+
--actor <id> audited actor for push/resolve
|
|
176
|
+
--device <id> audited device for push/resolve
|
|
177
|
+
--input <path> portable state (default: .wendkeep/portable/state.json)
|
|
178
|
+
--select <event-id> conflict candidate selected by resolve
|
|
179
|
+
--record <key> canonical conflict record key
|
|
180
|
+
--reason <text> audited resolution reason
|
|
181
|
+
--no-import pull protocol state without importing authored files
|
|
182
|
+
--json structured result
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
export function isSyncProtocolCommand(argv = []) {
|
|
186
|
+
return SUBCOMMANDS.has(argv[0]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function runSyncProtocol(argv = []) {
|
|
190
|
+
const sub = argv[0];
|
|
191
|
+
const json = argv.includes('--json');
|
|
192
|
+
try {
|
|
193
|
+
if (!SUBCOMMANDS.has(sub)) throw cliError('WENDKEEP_SYNC_SUBCOMMAND_INVALID', 'unknown sync protocol subcommand');
|
|
194
|
+
const { projectRoot, vaultBase, projectId } = runtime(argv);
|
|
195
|
+
let localState = readLocalSyncState(vaultBase, projectId);
|
|
196
|
+
let result;
|
|
197
|
+
if (sub === 'status') {
|
|
198
|
+
const outbox = inspectSyncOutbox(vaultBase);
|
|
199
|
+
result = { ok: true, enabled: outbox.status !== 'disabled', outbox, conflicts: openConflicts(localState).length };
|
|
200
|
+
} else if (sub === 'conflicts') {
|
|
201
|
+
result = { ok: true, conflicts: openConflicts(localState) };
|
|
202
|
+
} else if (sub === 'push') {
|
|
203
|
+
if (openConflicts(localState).length) {
|
|
204
|
+
throw cliError('WENDKEEP_SYNC_CONFLICT_OPEN', 'resolve local conflicts before pushing new revisions');
|
|
205
|
+
}
|
|
206
|
+
const adapter = adapterFrom(argv, projectRoot);
|
|
207
|
+
const input = resolve(option(argv, '--input') || join(projectRoot, '.wendkeep', 'portable', 'state.json'));
|
|
208
|
+
const portable = readPortable(input);
|
|
209
|
+
if (portable.project_id !== projectId) throw cliError('WENDKEEP_SYNC_PROJECT_MISMATCH', 'portable state belongs to another project');
|
|
210
|
+
const fresh = portableStateToSyncEvents(portable, localState, {
|
|
211
|
+
actorId: requiredOption(argv, '--actor'), deviceId: requiredOption(argv, '--device'),
|
|
212
|
+
});
|
|
213
|
+
for (const item of fresh) enqueueSyncEvent(vaultBase, item);
|
|
214
|
+
const pending = readPendingSyncEvents(vaultBase);
|
|
215
|
+
const pushed = await pushSyncEvents({
|
|
216
|
+
adapter, events: pending,
|
|
217
|
+
onAcknowledged: async (item) => { ackSyncEvent(vaultBase, item.event_id, { backend: adapter.id }); },
|
|
218
|
+
});
|
|
219
|
+
const cursor = readSyncCursor(vaultBase);
|
|
220
|
+
const pulled = await pullSyncEvents({ adapter, cursor, projectId });
|
|
221
|
+
if (pulled.state?.project_id && pulled.state.project_id !== projectId) {
|
|
222
|
+
throw cliError('WENDKEEP_SYNC_PROJECT_MISMATCH', 'remote state belongs to another project');
|
|
223
|
+
}
|
|
224
|
+
if (pulled.state) localState = pulled.state;
|
|
225
|
+
else for (const item of pulled.events || pending) applySyncEvent(localState, item);
|
|
226
|
+
writeLocalSyncState(vaultBase, localState);
|
|
227
|
+
writeSyncCursor(vaultBase, Number(pulled.cursor || cursor));
|
|
228
|
+
result = {
|
|
229
|
+
ok: true, generated: fresh.length, pushed: pushed.length, results: pushed,
|
|
230
|
+
conflicts: openConflicts(localState).length,
|
|
231
|
+
};
|
|
232
|
+
} else if (sub === 'pull') {
|
|
233
|
+
const adapter = adapterFrom(argv, projectRoot);
|
|
234
|
+
const cursor = readSyncCursor(vaultBase);
|
|
235
|
+
const pulled = await pullSyncEvents({ adapter, cursor, projectId });
|
|
236
|
+
if (pulled.state?.project_id && pulled.state.project_id !== projectId) {
|
|
237
|
+
throw cliError('WENDKEEP_SYNC_PROJECT_MISMATCH', 'remote state belongs to another project');
|
|
238
|
+
}
|
|
239
|
+
if (pulled.state) localState = pulled.state;
|
|
240
|
+
else for (const item of pulled.events || []) applySyncEvent(localState, item);
|
|
241
|
+
writeLocalSyncState(vaultBase, localState);
|
|
242
|
+
writeSyncCursor(vaultBase, Number(pulled.cursor || cursor));
|
|
243
|
+
let imported = null;
|
|
244
|
+
if (!argv.includes('--no-import') && Object.keys(localState.records || {}).length) {
|
|
245
|
+
imported = importPortableState({
|
|
246
|
+
vaultBase, projectRoot, state: syncStateToPortableState(localState),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
result = { ok: true, received: (pulled.events || []).length, cursor: pulled.cursor, imported, conflicts: openConflicts(localState).length };
|
|
250
|
+
} else {
|
|
251
|
+
const adapter = adapterFrom(argv, projectRoot);
|
|
252
|
+
const resolution = await adapter.resolve({
|
|
253
|
+
projectId,
|
|
254
|
+
recordKey: requiredOption(argv, '--record'),
|
|
255
|
+
selectedEventId: requiredOption(argv, '--select'),
|
|
256
|
+
actorId: requiredOption(argv, '--actor'),
|
|
257
|
+
deviceId: requiredOption(argv, '--device'),
|
|
258
|
+
reason: requiredOption(argv, '--reason'),
|
|
259
|
+
});
|
|
260
|
+
const pulled = await pullSyncEvents({ adapter, cursor: 0, projectId });
|
|
261
|
+
if (pulled.state) writeLocalSyncState(vaultBase, pulled.state);
|
|
262
|
+
writeSyncCursor(vaultBase, Number(pulled.cursor || 0));
|
|
263
|
+
result = { ok: true, resolution: resolution.decision || resolution };
|
|
264
|
+
}
|
|
265
|
+
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
266
|
+
else if (sub === 'status') process.stdout.write(`sync protocol: ${result.enabled ? 'enabled' : 'disabled'} · pending ${result.outbox.pending} · conflicts ${result.conflicts}\n`);
|
|
267
|
+
else if (sub === 'conflicts') process.stdout.write(`sync conflicts: ${result.conflicts.length}\n`);
|
|
268
|
+
else if (sub === 'push') process.stdout.write(`sync push: ${result.pushed} event(s) acknowledged\n`);
|
|
269
|
+
else if (sub === 'pull') process.stdout.write(`sync pull: ${result.received} event(s), ${result.conflicts} conflict(s)\n`);
|
|
270
|
+
else process.stdout.write('sync conflict resolved\n');
|
|
271
|
+
return 0;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const payload = { ok: false, code: error?.code || 'WENDKEEP_SYNC_FAILED', error: String(error?.message || error) };
|
|
274
|
+
process.stderr.write(json ? `${JSON.stringify(payload)}\n` : `wendkeep sync: ${payload.code}: ${payload.error}\n`);
|
|
275
|
+
return payload.code === 'WENDKEEP_SYNC_BACKEND_UNAVAILABLE' ? 1 : 2;
|
|
276
|
+
}
|
|
277
|
+
}
|