kenaz 0.3.11 → 0.3.12
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/core/.collaboration/core/task-2288-followups.test.js +2 -37
- package/core/.collaboration/core/task-2421-replica.test.js +229 -0
- package/core/.collaboration/core/task-api.js +221 -12
- package/core/.collaboration/core/task-replica.js +189 -0
- package/core/.formation/.merge-lock +1 -0
- package/core/.formation/bifrost/codex-doctor.js +12 -2
- package/core/.formation/bifrost/codex-doctor.test.js +15 -0
- package/core/.formation/bifrost/setup-codex.js +84 -16
- package/core/.formation/tools/post-complete/task2420.test.js +129 -0
- package/core/.formation/tools/post-complete.js +129 -8
- package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
- package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
- package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
- package/core/.specialist/tools/rea-detect.js +6 -2
- package/core/.synapse/memory-shared.json.sync +1 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
- package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
- package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
- package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
- package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
- package/core/plugins/kenaz/scripts/ygg.js +43 -4
- package/lib/cli.js +17 -7
- package/lib/cli.test.js +10 -0
- package/lib/dashboard.test.js +3 -3
- package/package.json +6 -6
- package/vendor/install-kenaz-cli.js +167 -16
- package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
|
@@ -9,11 +9,10 @@ const { spawnSync } = require('child_process');
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const os = require('os');
|
|
12
|
-
const cp = require('child_process');
|
|
13
12
|
|
|
14
13
|
const TASKAPI = path.join(__dirname, 'task-api.js');
|
|
15
14
|
const TASKJSON = path.join(__dirname, 'task-api-json.js');
|
|
16
|
-
const {
|
|
15
|
+
const { toYAML } = require('./task-api.js');
|
|
17
16
|
|
|
18
17
|
const SB = fs.mkdtempSync(path.join(os.tmpdir(), 't2288_'));
|
|
19
18
|
const tasksDir = path.join(SB, '.kenaz', 'tasks');
|
|
@@ -37,20 +36,6 @@ const field = (id, k) => (findCard(id).text.match(new RegExp('^' + k + ':\\s*(.*
|
|
|
37
36
|
const mkActive = (id) => fs.writeFileSync(path.join(tasksDir, 'active', id + '_t.yaml'),
|
|
38
37
|
toYAML({ id, title: 't2288 ' + id, priority: 'P2', status: 'in_progress' }));
|
|
39
38
|
|
|
40
|
-
// --- spawn counting seam (same as TASK_2264) ---
|
|
41
|
-
let phases = [];
|
|
42
|
-
const realSpawn = cp.spawn;
|
|
43
|
-
cp.spawn = function (cmd, argv, opts) {
|
|
44
|
-
const script = Array.isArray(argv) ? argv.find(a => typeof a === 'string' && a.includes('const CFG')) : null;
|
|
45
|
-
if (script) {
|
|
46
|
-
const m = script.match(/"phase":"(before|after)"/);
|
|
47
|
-
phases.push(m ? m[1] : '?');
|
|
48
|
-
return { unref() {}, on() {} };
|
|
49
|
-
}
|
|
50
|
-
return realSpawn.apply(this, arguments);
|
|
51
|
-
};
|
|
52
|
-
const afters = () => phases.filter(p => p === 'after').length;
|
|
53
|
-
|
|
54
39
|
(async () => {
|
|
55
40
|
try {
|
|
56
41
|
// ---- (1) actual-hours validation (CLI, subprocess so spawn patch is irrelevant) ----
|
|
@@ -90,30 +75,10 @@ const afters = () => phases.filter(p => p === 'after').length;
|
|
|
90
75
|
check('Z3 task-api-json emits estimated_hours 0 as 0', p2 && p2.estimated_hours === 0, JSON.stringify(p2 && p2.estimated_hours) + j2.stderr);
|
|
91
76
|
check('Z4 task-api-json emits actual_hours 0 as 0', p2 && p2.actual_hours === 0, JSON.stringify(p2 && p2.actual_hours));
|
|
92
77
|
}
|
|
93
|
-
|
|
94
|
-
// ---- (3) single 'after' capture ----
|
|
95
|
-
const api = new TaskAPI({ tasksDir, projectPath: SB });
|
|
96
|
-
for (const envName of ['FORMATION_HEADLESS', 'FORMATION_SCHEDULER']) {
|
|
97
|
-
const id = envName === 'FORMATION_HEADLESS' ? 'TASK_9111' : 'TASK_9112';
|
|
98
|
-
mkActive(id); phases = [];
|
|
99
|
-
process.env[envName] = '1';
|
|
100
|
-
try { await api.completeTask(id, {}); } finally { delete process.env[envName]; }
|
|
101
|
-
check(`R1 ${envName}: caller's post-complete owns 'after'; moveTask schedules none`, afters() === 0, JSON.stringify(phases));
|
|
102
|
-
}
|
|
103
|
-
mkActive('TASK_9113'); phases = [];
|
|
104
|
-
process.env.FORMATION_HEADLESS = '1';
|
|
105
|
-
try { await api.completeTask('TASK_9113', { skipPostComplete: true }); } finally { delete process.env.FORMATION_HEADLESS; }
|
|
106
|
-
check('R2 control: headless + skipPostComplete (nobody else measures) keeps exactly 1 capture', afters() === 1, JSON.stringify(phases));
|
|
107
|
-
mkActive('TASK_9114'); phases = [];
|
|
108
|
-
await api.moveTask('TASK_9114', 'done');
|
|
109
|
-
check('R3 control: bare move done captures exactly once', afters() === 1, JSON.stringify(phases));
|
|
110
|
-
mkActive('TASK_9115'); phases = [];
|
|
111
|
-
await api.completeTask('TASK_9115', { dryRun: true, skipGit: true, skipArchive: true });
|
|
112
|
-
check('R4 control: in-process complete (TASK_2264) still schedules none', afters() === 0, JSON.stringify(phases));
|
|
78
|
+
// (3) the single 'after' memory-health capture checks were removed with the capture itself (TASK_2411)
|
|
113
79
|
} catch (e) {
|
|
114
80
|
check('no exception', false, e && e.stack);
|
|
115
81
|
} finally {
|
|
116
|
-
cp.spawn = realSpawn;
|
|
117
82
|
try { fs.rmSync(SB, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
118
83
|
}
|
|
119
84
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* TASK_2421 -- shared-room card replicas, unknown-id lookup, import, alias,
|
|
4
|
+
* local_fallback warning. Uses a local stub relay (never the real relay) and
|
|
5
|
+
* temp project dirs. YGGDRASIL_PUBLISHABLE_KEY is set so ~/.kenaz is never read.
|
|
6
|
+
*/
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const http = require('http');
|
|
12
|
+
const { execFile } = require('child_process');
|
|
13
|
+
const { TaskAPI } = require('./task-api.js');
|
|
14
|
+
|
|
15
|
+
process.env.YGGDRASIL_PUBLISHABLE_KEY = 'test-key';
|
|
16
|
+
const CLI = path.join(__dirname, 'task-api.js');
|
|
17
|
+
|
|
18
|
+
function startRelay({ failAllocate = false } = {}) {
|
|
19
|
+
const state = { rows: new Map(), nextId: 9000, posts: [], patches: [], authSeen: [] };
|
|
20
|
+
const server = http.createServer((req, res) => {
|
|
21
|
+
let body = '';
|
|
22
|
+
req.on('data', (c) => { body += c; });
|
|
23
|
+
req.on('end', () => {
|
|
24
|
+
state.authSeen.push(req.headers.authorization);
|
|
25
|
+
const url = new URL(req.url, 'http://x');
|
|
26
|
+
const json = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
|
27
|
+
if (req.method === 'POST' && url.pathname === '/api/tasks/allocate-ids') {
|
|
28
|
+
if (failAllocate) return json(500, { error: 'down' });
|
|
29
|
+
const start = state.nextId; state.nextId += 20;
|
|
30
|
+
return json(200, { start, end: start + 19 });
|
|
31
|
+
}
|
|
32
|
+
if (req.method === 'POST' && url.pathname === '/api/tasks') {
|
|
33
|
+
const b = JSON.parse(body); state.posts.push(b);
|
|
34
|
+
state.rows.set(b.task_id, { ...b, claimed_by: null, assigned_to: b.assigned_to || null });
|
|
35
|
+
return json(200, state.rows.get(b.task_id));
|
|
36
|
+
}
|
|
37
|
+
if (req.method === 'PATCH' && url.pathname.startsWith('/api/tasks/')) {
|
|
38
|
+
const id = decodeURIComponent(url.pathname.split('/').pop());
|
|
39
|
+
const b = JSON.parse(body); state.patches.push({ id, ...b });
|
|
40
|
+
const row = state.rows.get(id);
|
|
41
|
+
if (!row) return json(409, { error: 'conflict' });
|
|
42
|
+
for (const k of ['status', 'title', 'description', 'priority', 'full_yaml']) if (b[k] != null) row[k] = b[k];
|
|
43
|
+
return json(200, row);
|
|
44
|
+
}
|
|
45
|
+
if (req.method === 'GET' && url.pathname === '/api/tasks') {
|
|
46
|
+
const wanted = (url.searchParams.get('status') || 'pool').split(',');
|
|
47
|
+
return json(200, [...state.rows.values()].filter(r => wanted.includes(r.status)));
|
|
48
|
+
}
|
|
49
|
+
json(404, {});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
|
|
53
|
+
resolve({ state, server, url: `ws://127.0.0.1:${server.address().port}` });
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mkProject(yamlText) {
|
|
58
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'task2421-'));
|
|
59
|
+
fs.mkdirSync(path.join(root, '.kenaz', 'tasks'), { recursive: true });
|
|
60
|
+
if (yamlText) fs.writeFileSync(path.join(root, '.kenaz', 'yggdrasil.yaml'), yamlText);
|
|
61
|
+
return { root, api: new TaskAPI({ projectPath: root, tasksDir: path.join(root, '.kenaz', 'tasks') }) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const cfg = (url, extra = '') => `enabled: true\nrelay_url: "${url}"\nyggdrasil_project: room\nnode_name: node-A\n${extra}`;
|
|
65
|
+
const card = (extra = {}) => ({ title: 'Replica me', description: 'desc', priority: 'P1', requirements: ['r1'], acceptance_criteria: ['a1'], force: true, ...extra });
|
|
66
|
+
|
|
67
|
+
function cli(root, args) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
execFile(process.execPath, [CLI, '--project-path', root, ...args], { env: process.env, timeout: 30000 },
|
|
70
|
+
(err, stdout, stderr) => resolve({ code: err ? (err.code || 1) : 0, stdout, stderr }));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tests = [];
|
|
75
|
+
const test = (n, f) => tests.push([n, f]);
|
|
76
|
+
|
|
77
|
+
test('shared room: create publishes a non-pool read-only replica', async () => {
|
|
78
|
+
const relay = await startRelay();
|
|
79
|
+
const { api } = mkProject(cfg(relay.url));
|
|
80
|
+
const t = await api.createTask(card({ alias: '791-17' }));
|
|
81
|
+
assert.strictEqual(relay.state.posts.length, 1);
|
|
82
|
+
const p = relay.state.posts[0];
|
|
83
|
+
assert.strictEqual(p.task_id, t.id);
|
|
84
|
+
assert.strictEqual(p.status, 'assigned', 'must not be claimable pool work');
|
|
85
|
+
assert.ok(!p.assigned_to, 'no assigned_to so creator inbox (pool_pull) stays clean');
|
|
86
|
+
assert.strictEqual(p.created_by, 'node-A');
|
|
87
|
+
assert.ok(/replica: true/.test(p.full_yaml) && /791-17/.test(p.full_yaml) && /origin_node: node-A/.test(p.full_yaml));
|
|
88
|
+
assert.ok(relay.state.authSeen.every(a => a === 'Bearer test-key'));
|
|
89
|
+
assert.strictEqual(t.replica_published, true);
|
|
90
|
+
relay.server.close();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('opted out (share_cards: false) -> no replica; ids still from relay', async () => {
|
|
94
|
+
const relay = await startRelay();
|
|
95
|
+
const { api } = mkProject(cfg(relay.url, 'share_cards: false\n'));
|
|
96
|
+
const t = await api.createTask(card());
|
|
97
|
+
assert.strictEqual(relay.state.posts.length, 0);
|
|
98
|
+
assert.ok(/^TASK_9\d{3}$/.test(t.id));
|
|
99
|
+
relay.server.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('not a shared room (no yggdrasil config) -> no relay traffic', async () => {
|
|
103
|
+
const relay = await startRelay();
|
|
104
|
+
const { api } = mkProject(null);
|
|
105
|
+
await api.createTask(card());
|
|
106
|
+
assert.strictEqual(relay.state.posts.length, 0);
|
|
107
|
+
relay.server.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('relay down: local create still succeeds with one replica warning', async () => {
|
|
111
|
+
const relay = await startRelay();
|
|
112
|
+
const { api } = mkProject(cfg(relay.url));
|
|
113
|
+
const first = await api.createTask(card()); // gets a lease while relay is up
|
|
114
|
+
await new Promise(r => relay.server.close(r));
|
|
115
|
+
const warns = [];
|
|
116
|
+
const orig = console.warn; console.warn = (...a) => warns.push(a.join(' '));
|
|
117
|
+
let t;
|
|
118
|
+
try { t = await api.createTask(card({ title: 'Second' })); } finally { console.warn = orig; }
|
|
119
|
+
assert.ok(t && t.id && t.id !== first.id);
|
|
120
|
+
assert.ok(fs.existsSync(t._file_path), 'local card written');
|
|
121
|
+
assert.strictEqual(t.replica_published, false);
|
|
122
|
+
assert.strictEqual(warns.filter(w => /replica not published/.test(w)).length, 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('local_fallback in a shared room warns loudly (stderr + create output)', async () => {
|
|
126
|
+
const { api, root } = mkProject(cfg('ws://127.0.0.1:1')); // unreachable relay, no lease
|
|
127
|
+
const r = await cli(root, ['create', '--title', 'Fallback', '--force']);
|
|
128
|
+
assert.strictEqual(r.code, 0, r.stderr);
|
|
129
|
+
assert.ok(/ID COLLISION RISK/.test(r.stderr), 'stderr warning');
|
|
130
|
+
assert.ok(/⚠️.*locally because the relay is unreachable/.test(r.stdout), 'create output warning');
|
|
131
|
+
assert.ok(api);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('solo project local id allocation prints no collision warning', async () => {
|
|
135
|
+
const { root } = mkProject(null);
|
|
136
|
+
const r = await cli(root, ['create', '--title', 'Solo', '--force']);
|
|
137
|
+
assert.strictEqual(r.code, 0, r.stderr);
|
|
138
|
+
assert.ok(!/COLLISION/.test(r.stderr + r.stdout));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('get unknown id shows replica with owner node; mutations explain, create no local file', async () => {
|
|
142
|
+
const relay = await startRelay();
|
|
143
|
+
const a = mkProject(cfg(relay.url));
|
|
144
|
+
const t = await a.api.createTask(card());
|
|
145
|
+
// node B: same room, empty local store
|
|
146
|
+
const b = mkProject(cfg(relay.url).replace('node-A', 'node-B'));
|
|
147
|
+
const g = await cli(b.root, ['get', t.id]);
|
|
148
|
+
assert.strictEqual(g.code, 0, g.stderr);
|
|
149
|
+
const out = JSON.parse(g.stdout);
|
|
150
|
+
assert.strictEqual(out.read_only_replica, true);
|
|
151
|
+
assert.strictEqual(out.title, 'Replica me');
|
|
152
|
+
assert.ok(/read-only replica; card lives on node node-A/.test(out.replica_notice));
|
|
153
|
+
assert.ok(/task-api\.js pull /.test(out.replica_notice));
|
|
154
|
+
for (const args of [['move', t.id, 'active'], ['update', t.id, '--title', 'x'], ['complete', t.id]]) {
|
|
155
|
+
const r = await cli(b.root, args);
|
|
156
|
+
assert.notStrictEqual(r.code, 0, args.join(' '));
|
|
157
|
+
assert.ok(/read-only replica; card lives on node node-A/.test(r.stderr + r.stdout), args.join(' ') + ': ' + r.stderr);
|
|
158
|
+
}
|
|
159
|
+
const files = fs.readdirSync(path.join(b.root, '.kenaz', 'tasks'), { recursive: true }).filter(f => /TASK_/.test(String(f)));
|
|
160
|
+
assert.strictEqual(files.length, 0, 'no broken local file: ' + files.join(','));
|
|
161
|
+
// truly unknown id still says not found
|
|
162
|
+
const miss = await cli(b.root, ['get', 'TASK_424242']);
|
|
163
|
+
assert.notStrictEqual(miss.code, 0);
|
|
164
|
+
assert.ok(/任務不存在/.test(miss.stderr));
|
|
165
|
+
relay.server.close();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('status changes on the owner keep the replica current', async () => {
|
|
169
|
+
const relay = await startRelay();
|
|
170
|
+
const { api } = mkProject(cfg(relay.url));
|
|
171
|
+
const t = await api.createTask(card());
|
|
172
|
+
await api.moveTask(t.id, 'active');
|
|
173
|
+
assert.strictEqual(relay.state.rows.get(t.id).status, 'in_progress');
|
|
174
|
+
await api.updateTask(t.id, { title: 'Renamed' });
|
|
175
|
+
assert.strictEqual(relay.state.rows.get(t.id).title, 'Renamed');
|
|
176
|
+
relay.server.close();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('pull imports a local copy marked with origin node', async () => {
|
|
180
|
+
const relay = await startRelay();
|
|
181
|
+
const a = mkProject(cfg(relay.url));
|
|
182
|
+
const t = await a.api.createTask(card({ alias: '791-18' }));
|
|
183
|
+
const b = mkProject(cfg(relay.url).replace('node-A', 'node-B'));
|
|
184
|
+
const r = await cli(b.root, ['pull', t.id]);
|
|
185
|
+
assert.strictEqual(r.code, 0, r.stderr);
|
|
186
|
+
const local = await b.api.getTask(t.id);
|
|
187
|
+
assert.ok(local, 'local copy exists');
|
|
188
|
+
assert.strictEqual(local.origin_node, 'node-A');
|
|
189
|
+
assert.strictEqual(local.title, 'Replica me');
|
|
190
|
+
assert.strictEqual(local.alias, '791-18');
|
|
191
|
+
const again = await cli(b.root, ['pull', t.id]);
|
|
192
|
+
assert.notStrictEqual(again.code, 0);
|
|
193
|
+
relay.server.close();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('alias: create/update/get/search resolve to the TASK id', async () => {
|
|
197
|
+
const { api, root } = mkProject(null);
|
|
198
|
+
const t = await api.createTask(card({ alias: '791-17' }));
|
|
199
|
+
const g = await cli(root, ['get', '791-17']);
|
|
200
|
+
assert.strictEqual(g.code, 0, g.stderr);
|
|
201
|
+
assert.strictEqual(JSON.parse(g.stdout).id, t.id);
|
|
202
|
+
const s = await cli(root, ['search', '791-17']);
|
|
203
|
+
assert.ok(s.stdout.includes(t.id));
|
|
204
|
+
const u = await cli(root, ['update', t.id, '--alias', '791-99']);
|
|
205
|
+
assert.strictEqual(u.code, 0, u.stderr);
|
|
206
|
+
const g2 = await cli(root, ['get', '791-99']);
|
|
207
|
+
assert.strictEqual(JSON.parse(g2.stdout).id, t.id);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('get by alias of a replica that exists only on the relay', async () => {
|
|
211
|
+
const relay = await startRelay();
|
|
212
|
+
const a = mkProject(cfg(relay.url));
|
|
213
|
+
const t = await a.api.createTask(card({ alias: '791-20' }));
|
|
214
|
+
const b = mkProject(cfg(relay.url).replace('node-A', 'node-B'));
|
|
215
|
+
const g = await cli(b.root, ['get', '791-20']);
|
|
216
|
+
assert.strictEqual(g.code, 0, g.stderr);
|
|
217
|
+
assert.strictEqual(JSON.parse(g.stdout).id, t.id);
|
|
218
|
+
relay.server.close();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
(async () => {
|
|
222
|
+
let failed = 0;
|
|
223
|
+
for (const [name, fn] of tests) {
|
|
224
|
+
try { await fn(); console.log(` ok ${name}`); }
|
|
225
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e && e.stack || e}`); }
|
|
226
|
+
}
|
|
227
|
+
console.log(`\n${tests.length - failed}/${tests.length} passed`);
|
|
228
|
+
process.exit(failed ? 1 : 0);
|
|
229
|
+
})();
|