groove-dev 0.27.206 → 0.27.208

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.
Files changed (41) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/axom-connector.js +50 -2
  4. package/node_modules/@groove-dev/daemon/src/axom-remote.js +16 -14
  5. package/node_modules/@groove-dev/daemon/src/axom-runtimes.js +301 -0
  6. package/node_modules/@groove-dev/daemon/src/axom-server.js +23 -4
  7. package/node_modules/@groove-dev/daemon/src/chatstore.js +112 -21
  8. package/node_modules/@groove-dev/daemon/src/index.js +16 -1
  9. package/node_modules/@groove-dev/daemon/src/routes/agents.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -0
  11. package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +6 -3
  12. package/node_modules/@groove-dev/daemon/src/routes/watch.js +11 -0
  13. package/node_modules/@groove-dev/daemon/src/watcher.js +55 -2
  14. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
  15. package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +198 -0
  16. package/node_modules/@groove-dev/daemon/test/chatstore.test.js +111 -19
  17. package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -2
  18. package/node_modules/@groove-dev/gui/dist/assets/{index-BYQ4qIHh.js → index-3Lzv2-To.js} +235 -235
  19. package/node_modules/@groove-dev/gui/dist/assets/index-Bh_HF8ed.css +1 -0
  20. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  21. package/node_modules/@groove-dev/gui/package.json +1 -1
  22. package/package.json +1 -1
  23. package/packages/cli/package.json +1 -1
  24. package/packages/daemon/package.json +1 -1
  25. package/packages/daemon/src/axom-connector.js +50 -2
  26. package/packages/daemon/src/axom-remote.js +16 -14
  27. package/packages/daemon/src/axom-runtimes.js +301 -0
  28. package/packages/daemon/src/axom-server.js +23 -4
  29. package/packages/daemon/src/chatstore.js +112 -21
  30. package/packages/daemon/src/index.js +16 -1
  31. package/packages/daemon/src/routes/agents.js +4 -0
  32. package/packages/daemon/src/routes/axom.js +73 -0
  33. package/packages/daemon/src/routes/chat-history.js +6 -3
  34. package/packages/daemon/src/routes/watch.js +11 -0
  35. package/packages/daemon/src/watcher.js +55 -2
  36. package/packages/gui/dist/assets/{index-BYQ4qIHh.js → index-3Lzv2-To.js} +235 -235
  37. package/packages/gui/dist/assets/index-Bh_HF8ed.css +1 -0
  38. package/packages/gui/dist/index.html +2 -2
  39. package/packages/gui/package.json +1 -1
  40. package/node_modules/@groove-dev/gui/dist/assets/index-DG6yq4dB.css +0 -1
  41. package/packages/gui/dist/assets/index-DG6yq4dB.css +0 -1
@@ -0,0 +1,198 @@
1
+ // GROOVE — Axom Runtime Model Tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // The model is exercised with faked backends: what matters here is that verbs
5
+ // dispatch on control mode, states derive honestly, and migration folds the
6
+ // legacy keys without forking anyone's ledger.
7
+
8
+ import { describe, it, beforeEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { tmpdir } from 'os';
13
+ import { AxomRuntimes, validateRuntime } from '../src/axom-runtimes.js';
14
+
15
+ function fakeDaemon() {
16
+ const calls = { remote: [], server: [], connector: [], nudges: [], rechecks: [] };
17
+ const broadcasts = [];
18
+ return {
19
+ calls,
20
+ broadcasts,
21
+ grooveDir: mkdtempSync(join(tmpdir(), 'groove-axrt-')),
22
+ config: { axom: {} },
23
+ broadcast(m) { broadcasts.push(m); },
24
+ audit: { log() {} },
25
+ axom: {
26
+ endpoints: new Map(),
27
+ configure(entries) { calls.connector.push(entries); },
28
+ nudge(name) { calls.nudges.push(name); },
29
+ recheck(name) { calls.rechecks.push(name); },
30
+ },
31
+ axomRemote: {
32
+ async status(cfg) { calls.remote.push(['status', cfg]); return fakeDaemon._remoteStatus || { running: false }; },
33
+ async start(cfg) { calls.remote.push(['start', cfg]); return { started: true }; },
34
+ async stop(o, cfg) { calls.remote.push(['stop', o, cfg]); return { stopped: true, via: 'shutdown' }; },
35
+ async ensureTunnel(cfg) { calls.remote.push(['tunnel', cfg]); return { tunneled: true }; },
36
+ },
37
+ axomServer: {
38
+ instances: [],
39
+ list() { return this.instances; },
40
+ async start(id, opts) { calls.server.push(['start', id, opts]); return { id, port: 8737, status: 'running' }; },
41
+ async stop(id) { calls.server.push(['stop', id]); },
42
+ },
43
+ };
44
+ }
45
+
46
+ const SSH_RT = {
47
+ id: 'spark', name: 'Spark', url: 'http://127.0.0.1:8737', control: 'ssh',
48
+ ssh: { host: 'spark.local', user: 'axom' },
49
+ launch: { command: 'cd /x && PYTHONPATH=model python3 -u -m axom.cli serve --cpu' },
50
+ };
51
+
52
+ describe('AxomRuntimes', () => {
53
+ let daemon, model;
54
+
55
+ beforeEach(() => {
56
+ daemon = fakeDaemon();
57
+ model = new AxomRuntimes(daemon);
58
+ // The real broadcastStatus probes runtime URLs; record the push instead so
59
+ // these tests stay hermetic. The genuine payload has its own test below.
60
+ model.broadcastStatus = async () => { daemon.broadcasts.push({ type: 'axom:runtimes' }); };
61
+ });
62
+
63
+ it('migrates the legacy endpoint+remote pair into one ssh runtime', () => {
64
+ daemon.config.axom = {
65
+ endpoints: [{ name: 'local', url: 'http://127.0.0.1:8737' }],
66
+ remote: { host: 'edgexpert-aaa6.local', user: 'axom', port: 8737, command: 'python3 -m axom.cli serve', logPath: '/x.log' },
67
+ };
68
+ assert.equal(model.migrate(), true);
69
+ const [rt] = model.list();
70
+ assert.equal(rt.control, 'ssh');
71
+ assert.equal(rt.url, 'http://127.0.0.1:8737');
72
+ assert.equal(rt.ssh.host, 'edgexpert-aaa6.local');
73
+ assert.equal(rt.launch.command, 'python3 -m axom.cli serve');
74
+ assert.equal(model.activeId(), rt.id);
75
+ assert.equal(model.migrate(), false); // idempotent
76
+ });
77
+
78
+ it('migrates a lone endpoint into a connect-only runtime', () => {
79
+ daemon.config.axom = { endpoints: [{ name: 'other', url: 'http://127.0.0.1:9999' }] };
80
+ model.migrate();
81
+ assert.equal(model.list()[0].control, 'none');
82
+ });
83
+
84
+ it('start dispatches by control: ssh starts remotely and heals the tunnel', async () => {
85
+ model.add(SSH_RT);
86
+ await model.startRuntime('spark');
87
+ const kinds = daemon.calls.remote.map((c) => c[0]);
88
+ assert.ok(kinds.includes('start'));
89
+ assert.ok(kinds.includes('tunnel')); // reachability follows lifecycle
90
+ const cfg = daemon.calls.remote.find((c) => c[0] === 'start')[1];
91
+ assert.equal(cfg.host, 'spark.local');
92
+ assert.equal(cfg.command, SSH_RT.launch.command); // spec passed VERBATIM
93
+ });
94
+
95
+ it('start dispatches by control: local spawns and adopts the resulting port as its URL', async () => {
96
+ model.add({ id: 'here', name: 'This machine', control: 'local', launch: { command: 'axom' }, dataDir: '/home/axom/axom-serve/data' });
97
+ await model.startRuntime('here');
98
+ const [, id, opts] = daemon.calls.server[0];
99
+ assert.equal(id, 'here');
100
+ assert.equal(opts.dataDir, '/home/axom/axom-serve/data'); // ADOPTS the ledger
101
+ assert.equal(model.get('here').url, 'http://127.0.0.1:8737');
102
+ });
103
+
104
+ it('refuses lifecycle verbs on connect-only runtimes with an honest message', async () => {
105
+ model.add({ id: 'theirs', name: 'Theirs', url: 'http://127.0.0.1:7000', control: 'none' });
106
+ await assert.rejects(() => model.startRuntime('theirs'), /not controlled by GROOVE/);
107
+ await assert.rejects(() => model.stopRuntime('theirs'), /not controlled by GROOVE/);
108
+ await assert.rejects(() => model.heal('theirs'), /only ssh runtimes/);
109
+ });
110
+
111
+ it('derives ssh states honestly: tunnel-down vs stopped vs host-unreachable', async () => {
112
+ model.add({ ...SSH_RT, url: 'http://127.0.0.1:1' }); // nothing listens on :1
113
+ fakeDaemon._remoteStatus = { running: true };
114
+ assert.equal((await model.state('spark')).state, 'unreachable'); // up on host, tunnel down
115
+ fakeDaemon._remoteStatus = { running: false };
116
+ assert.equal((await model.state('spark')).state, 'stopped');
117
+ fakeDaemon._remoteStatus = { running: null, error: 'no route to host' };
118
+ const s = await model.state('spark');
119
+ assert.equal(s.state, 'unreachable');
120
+ assert.match(s.detail, /no route/);
121
+ delete fakeDaemon._remoteStatus;
122
+ });
123
+
124
+ it('reports connected when the connector has the stream', async () => {
125
+ model.add(SSH_RT);
126
+ daemon.axom.endpoints.set('spark', { status: 'connected' });
127
+ assert.equal((await model.state('spark')).state, 'connected');
128
+ });
129
+
130
+ it('status exposes exactly the verbs each state supports', async () => {
131
+ model.add({ id: 'theirs', name: 'Theirs', url: 'http://127.0.0.1:1', control: 'none' });
132
+ const { runtimes } = await model.status();
133
+ const theirs = runtimes.find((r) => r.id === 'theirs');
134
+ assert.equal(theirs.canStart, false);
135
+ assert.equal(theirs.canStop, false);
136
+ assert.equal(theirs.canHeal, false);
137
+ });
138
+
139
+ it('keeps the connector in sync with the runtime list', () => {
140
+ model.add(SSH_RT);
141
+ model.remove('spark');
142
+ const last = daemon.calls.connector.pop();
143
+ assert.deepEqual(last, []);
144
+ });
145
+
146
+ // Axom-UX flag 1: the GUI's runtime cards must move on events, not a poll.
147
+ it('pushes fresh runtime state on every mutation and lifecycle verb', async () => {
148
+ const seen = () => daemon.broadcasts.filter((b) => b.type === 'axom:runtimes').length;
149
+ model.add(SSH_RT);
150
+ assert.ok(seen() >= 1);
151
+ let n = seen();
152
+ model.update('spark', { name: 'Spark 2' });
153
+ assert.ok(seen() > n);
154
+ n = seen();
155
+ model.activate('spark');
156
+ assert.ok(seen() > n);
157
+ n = seen();
158
+ await model.startRuntime('spark');
159
+ assert.ok(seen() > n);
160
+ // Axom-UX flag 3: start pulls the connector in NOW — 'running' must not
161
+ // linger for a backoff cycle before becoming 'connected'.
162
+ assert.deepEqual(daemon.calls.nudges, ['spark']);
163
+ n = seen();
164
+ await model.stopRuntime('spark');
165
+ assert.ok(seen() > n);
166
+ // ...and stop re-probes so a stale 'connected' collapses with the verb.
167
+ assert.deepEqual(daemon.calls.rechecks, ['spark']);
168
+ n = seen();
169
+ model.remove('spark');
170
+ assert.ok(seen() > n);
171
+ });
172
+
173
+ it('broadcastStatus emits the full axom:runtimes payload', async () => {
174
+ const d = fakeDaemon();
175
+ const m = new AxomRuntimes(d);
176
+ m.add({ id: 'here', name: 'This machine', control: 'local' }); // no URL → no probe
177
+ const deadline = Date.now() + 2000;
178
+ while (!d.broadcasts.some((b) => b.type === 'axom:runtimes')) {
179
+ if (Date.now() > deadline) throw new Error('no axom:runtimes broadcast');
180
+ await new Promise((r) => setTimeout(r, 10));
181
+ }
182
+ const msg = d.broadcasts.find((b) => b.type === 'axom:runtimes');
183
+ assert.equal(msg.data.activeRuntimeId, 'here');
184
+ assert.equal(msg.data.runtimes[0].state, 'stopped');
185
+ assert.equal(msg.data.runtimes[0].canStart, true);
186
+ });
187
+ });
188
+
189
+ describe('validateRuntime', () => {
190
+ it('accepts the three shapes and rejects garbage', () => {
191
+ assert.equal(validateRuntime(SSH_RT), null);
192
+ assert.equal(validateRuntime({ id: 'x', name: 'X', control: 'local' }), null); // url comes from spawn
193
+ assert.equal(validateRuntime({ id: 'y', name: 'Y', url: 'http://127.0.0.1:1', control: 'none' }), null);
194
+ assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'teleport' }));
195
+ assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'none' })); // none requires url
196
+ assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'ssh', url: 'http://127.0.0.1:1', ssh: { host: 'h;rm', user: 'u' } }));
197
+ });
198
+ });
@@ -6,28 +6,101 @@ import assert from 'node:assert/strict';
6
6
  import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
7
7
  import { tmpdir } from 'os';
8
8
  import { resolve } from 'path';
9
- import { ChatStore } from '../src/chatstore.js';
9
+ import { ChatStore, mergeMessages } from '../src/chatstore.js';
10
+
11
+ // Minimal registry: id -> {id, name}. Lets tests exercise the id→name
12
+ // resolution that makes history survive rotation.
13
+ function makeDaemon(agents = []) {
14
+ const map = new Map(agents.map((a) => [a.id, a]));
15
+ return {
16
+ grooveDir: null, // set per test
17
+ registry: {
18
+ _map: map,
19
+ get(id) { return map.get(id) || null; },
20
+ getAll() { return [...map.values()]; },
21
+ set(a) { map.set(a.id, a); },
22
+ delete(id) { map.delete(id); },
23
+ },
24
+ };
25
+ }
10
26
 
11
27
  describe('ChatStore', () => {
12
- let dir, store;
28
+ let dir, daemon, store;
13
29
 
14
30
  beforeEach(() => {
15
31
  dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-'));
16
- store = new ChatStore({ grooveDir: dir });
32
+ daemon = makeDaemon([{ id: 'a1', name: 'fullstack-1' }, { id: 'a2', name: 'planner-1' }]);
33
+ daemon.grooveDir = dir;
34
+ store = new ChatStore(daemon);
17
35
  });
18
36
  afterEach(() => {
19
37
  store.stop();
20
38
  try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
21
39
  });
22
40
 
23
- it('appends and returns per-agent history', () => {
41
+ it('appends under the agent NAME when given an id', () => {
24
42
  store.append('a1', { from: 'user', text: 'hello', timestamp: 1 });
25
- store.append('a1', { from: 'agent', text: 'hi', timestamp: 2 });
26
- store.append('a2', { from: 'user', text: 'other', timestamp: 3 });
43
+ store.append('fullstack-1', { from: 'agent', text: 'hi', timestamp: 2 });
44
+
45
+ // Both routes land in one bucket — the name.
46
+ assert.deepEqual(store.get('a1').map((m) => m.text), ['hello', 'hi']);
47
+ assert.deepEqual(store.getAll()['fullstack-1'].map((m) => m.text), ['hello', 'hi']);
48
+ assert.equal(store.getAll().a1, undefined);
49
+ });
50
+
51
+ it('history survives rotation: new id, same name, same history', () => {
52
+ store.append('a1', { from: 'user', text: 'before rotation', timestamp: 1 });
53
+
54
+ // Rotation mints a new id; the name is stable.
55
+ daemon.registry.delete('a1');
56
+ daemon.registry.set({ id: 'a1-r9', name: 'fullstack-1' });
57
+
58
+ store.append('a1-r9', { from: 'agent', text: 'after rotation', timestamp: 2 });
59
+ assert.deepEqual(store.get('a1-r9').map((m) => m.text), ['before rotation', 'after rotation']);
60
+ });
61
+
62
+ it('remap() folds a dead-id entry into the live agent', () => {
63
+ // Simulates data written under an id while the registry didn't know it
64
+ // (stale client post mid-rotation) — the rotation:complete hook remaps it.
65
+ store.history['dead-id'] = [{ from: 'user', text: 'stranded', timestamp: 1 }];
66
+ store.remap('dead-id', 'a1');
67
+ assert.equal(store.history['dead-id'], undefined);
68
+ assert.deepEqual(store.get('a1').map((m) => m.text), ['stranded']);
69
+ });
70
+
71
+ it('migrate() reattaches id-keyed histories from the old store format', () => {
72
+ store.history = {
73
+ a1: [{ from: 'user', text: 'legacy', timestamp: 1 }],
74
+ 'fullstack-1': [{ from: 'agent', text: 'current', timestamp: 2 }],
75
+ 'ghost-id': [{ from: 'user', text: 'unresolvable', timestamp: 3 }],
76
+ };
77
+ const moved = store.migrate();
78
+ assert.equal(moved, 1);
79
+ assert.deepEqual(store.getAll()['fullstack-1'].map((m) => m.text), ['legacy', 'current']);
80
+ // Unresolvable keys are parked, never dropped.
81
+ assert.equal(store.getAll()['ghost-id'][0].text, 'unresolvable');
82
+ });
27
83
 
28
- assert.equal(store.get('a1').length, 2);
29
- assert.equal(store.get('a2').length, 1);
30
- assert.deepEqual(store.getAll().a1.map((m) => m.text), ['hello', 'hi']);
84
+ it('view() keys live agents by CURRENT id and parks the rest by name', () => {
85
+ store.append('a1', { from: 'user', text: 'live', timestamp: 1 });
86
+ store.history['departed-agent'] = [{ from: 'user', text: 'old', timestamp: 2 }];
87
+ const v = store.view();
88
+ assert.equal(v.a1[0].text, 'live'); // fullstack-1 → its live id
89
+ assert.equal(v['fullstack-1'], undefined);
90
+ assert.equal(v['departed-agent'][0].text, 'old'); // no live agent — name key
91
+ });
92
+
93
+ it('merge() is a union — a sparse client can never truncate server history', () => {
94
+ for (let i = 0; i < 10; i++) store.append('a1', { from: 'user', text: `m${i}`, timestamp: i });
95
+ // Client syncs only 2 messages (1 duplicate, 1 new).
96
+ store.merge('a1', [
97
+ { from: 'user', text: 'm3', timestamp: 3 },
98
+ { from: 'agent', text: 'fresh', timestamp: 99 },
99
+ ]);
100
+ const texts = store.get('a1').map((m) => m.text);
101
+ assert.equal(texts.length, 11);
102
+ assert.equal(texts.at(-1), 'fresh');
103
+ assert.ok(texts.includes('m0') && texts.includes('m9'));
31
104
  });
32
105
 
33
106
  it('strips attachment data URLs but keeps metadata', () => {
@@ -44,7 +117,7 @@ describe('ChatStore', () => {
44
117
  store.append('a1', { from: 'user', text: 'durable', timestamp: 1 });
45
118
  store.stop(); // flushes synchronously
46
119
 
47
- const reloaded = new ChatStore({ grooveDir: dir });
120
+ const reloaded = new ChatStore(daemon);
48
121
  assert.equal(reloaded.get('a1')[0].text, 'durable');
49
122
  reloaded.stop();
50
123
  });
@@ -55,7 +128,7 @@ describe('ChatStore', () => {
55
128
  assert.ok(existsSync(resolve(dir, 'chat-history.json')));
56
129
  assert.ok(!existsSync(resolve(dir, 'chat-history.json.tmp')));
57
130
  const onDisk = JSON.parse(readFileSync(resolve(dir, 'chat-history.json'), 'utf8'));
58
- assert.equal(onDisk.a1[0].text, 'x');
131
+ assert.equal(onDisk['fullstack-1'][0].text, 'x');
59
132
  });
60
133
 
61
134
  it('caps per-agent history', () => {
@@ -66,21 +139,40 @@ describe('ChatStore', () => {
66
139
  assert.equal(h.at(-1).text, 'm249');
67
140
  });
68
141
 
69
- it('replace() swaps an agent history wholesale', () => {
70
- store.append('a1', { from: 'user', text: 'old' });
71
- store.replace('a1', [{ from: 'user', text: 'new1' }, { from: 'agent', text: 'new2' }]);
72
- assert.deepEqual(store.get('a1').map((m) => m.text), ['new1', 'new2']);
73
- });
74
-
75
- it('remove() deletes an agent history', () => {
142
+ it('remove() deletes by name so a purge cannot haunt a future same-name agent', () => {
76
143
  store.append('a1', { from: 'user', text: 'x' });
77
- store.remove('a1');
144
+ store.remove('fullstack-1');
78
145
  assert.equal(store.get('a1').length, 0);
79
146
  });
80
147
 
148
+ it('works without a registry (refs pass through verbatim)', () => {
149
+ const bare = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-bare-')) });
150
+ bare.append('some-id', { from: 'user', text: 'x', timestamp: 1 });
151
+ assert.equal(bare.get('some-id')[0].text, 'x');
152
+ bare.stop();
153
+ });
154
+
81
155
  it('starts empty when the file is missing or corrupt', () => {
82
156
  const empty = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-empty-')) });
83
157
  assert.deepEqual(empty.getAll(), {});
84
158
  empty.stop();
85
159
  });
86
160
  });
161
+
162
+ describe('mergeMessages', () => {
163
+ it('dedupes on (timestamp, from, text) and sorts by time', () => {
164
+ const merged = mergeMessages(
165
+ [{ from: 'u', text: 'b', timestamp: 2 }, { from: 'u', text: 'a', timestamp: 1 }],
166
+ [{ from: 'u', text: 'a', timestamp: 1 }, { from: 'u', text: 'c', timestamp: 3 }],
167
+ );
168
+ assert.deepEqual(merged.map((m) => m.text), ['a', 'b', 'c']);
169
+ });
170
+
171
+ it('treats same text at different timestamps as distinct messages', () => {
172
+ const merged = mergeMessages(
173
+ [{ from: 'u', text: 'ok', timestamp: 1 }],
174
+ [{ from: 'u', text: 'ok', timestamp: 2 }],
175
+ );
176
+ assert.equal(merged.length, 2);
177
+ });
178
+ });
@@ -143,9 +143,11 @@ describe('Watcher', () => {
143
143
  assert.throws(() => watcher.create('nope', { command: 'x' }), /Agent not found/);
144
144
  });
145
145
 
146
+ // Distinct commands — identical ones now re-attach to the running job rather
147
+ // than stacking up, so they would never reach the cap.
146
148
  it('caps active watches per agent', () => {
147
- for (let i = 0; i < 5; i++) watcher.create('a1', { command: 'sleep 5', label: `w${i}` });
148
- assert.throws(() => watcher.create('a1', { command: 'sleep 5' }), /already have 5/);
149
+ for (let i = 0; i < 5; i++) watcher.create('a1', { command: `sleep 5 # ${i}`, label: `w${i}` });
150
+ assert.throws(() => watcher.create('a1', { command: 'sleep 5 # 6' }), /already have 5/);
149
151
  });
150
152
 
151
153
  it('cancels a watch and stops its process', async () => {
@@ -206,6 +208,70 @@ describe('Watcher', () => {
206
208
  watcher2.stop();
207
209
  });
208
210
 
211
+ // ── no double-launch (regression: daemon rebuild re-ran a live job) ──
212
+ //
213
+ // A daemon restart resumes the agent mid-turn, so it re-issues the watch it
214
+ // believes never completed. Launching a second copy of a long job corrupts
215
+ // the first one's output and competes for the same hardware.
216
+
217
+ it('re-attaches instead of launching a second copy of a running command', async () => {
218
+ const marker = resolve(grooveDir, 'runs.txt');
219
+ const cmd = `echo run >> ${marker}; sleep 3`;
220
+
221
+ const first = watcher.create('a1', { command: cmd, label: 'champion v11' });
222
+ await settle(300);
223
+
224
+ const second = watcher.create('a1', { command: cmd, label: 'champion v11' });
225
+ assert.equal(second.id, first.id, 'the same watch is returned, not a new one');
226
+ assert.equal(second.reattached, true, 'the caller is told it re-attached');
227
+ assert.equal(
228
+ watcher.list().filter((w) => w.status === 'active').length, 1,
229
+ 'only one active watch exists',
230
+ );
231
+
232
+ await settle(400);
233
+ const runs = readFileSync(marker, 'utf8').trim().split('\n').filter(Boolean);
234
+ assert.equal(runs.length, 1, 'the command executed exactly once');
235
+ });
236
+
237
+ it('does not re-attach once the original job has exited', async () => {
238
+ const cmd = 'exit 0';
239
+ const first = watcher.create('a1', { command: cmd, label: 'short' });
240
+ await settle(500); // job finishes and the watch fires
241
+
242
+ const second = watcher.create('a1', { command: cmd, label: 'short again' });
243
+ assert.notEqual(second.id, first.id, 'a finished job may legitimately be re-run');
244
+ assert.ok(!second.reattached);
245
+ });
246
+
247
+ it('reports a job that vanished while the daemon was down instead of polling to timeout', async () => {
248
+ const w = watcher.create('a1', { command: 'sleep 30', label: 'doomed' });
249
+ await settle(200);
250
+ watcher._killJob(watcher.watches.get(w.id)); // job dies during the outage
251
+ watcher.stop();
252
+ await settle(200);
253
+
254
+ const daemon2 = makeDaemon(grooveDir);
255
+ wireDelivery(daemon2);
256
+ const watcher2 = new Watcher(daemon2);
257
+ daemon2.watcher = watcher2;
258
+ daemon2.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
259
+
260
+ watcher2.restore();
261
+ await settle(120);
262
+ assert.equal(daemon2.delivered.length, 1, 'the agent is told the job was lost');
263
+ assert.match(daemon2.delivered[0].message, /lost while the daemon was down/);
264
+ watcher2.stop();
265
+ });
266
+
267
+ it('appends to the run log so a re-run cannot erase the record', () => {
268
+ const w = watcher.create('a1', { command: 'echo hi', label: 'log' });
269
+ const script = readFileSync(resolve(grooveDir, 'watch-runs', w.id, 'run.sh'), 'utf8');
270
+ const outLine = script.split('\n').find((l) => l.includes('out.log'));
271
+ assert.ok(outLine.includes('>>'), 'the output redirect appends');
272
+ assert.ok(!/[^>]>\s*'[^']*out\.log/.test(outLine), 'the output log is never truncated');
273
+ });
274
+
209
275
  it('persists watches to disk', () => {
210
276
  watcher.create('a1', { command: 'sleep 5', label: 'persisted' });
211
277
  const raw = JSON.parse(readFileSync(resolve(grooveDir, 'watches.json'), 'utf8'));