@davvidbaker/flambe-cli 0.1.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/bin/flambe.mjs +13 -0
- package/package.json +20 -0
- package/src/cli.mjs +427 -0
- package/src/client.mjs +549 -0
- package/src/env.mjs +21 -0
- package/src/local/agents.mjs +24 -0
- package/src/local/http.mjs +391 -0
- package/src/local/serve.mjs +61 -0
- package/src/local/store.mjs +739 -0
- package/src/queue.mjs +161 -0
- package/test/client.test.mjs +951 -0
- package/test/local.test.mjs +136 -0
- package/test/queue.test.mjs +135 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { run } from '../src/cli.mjs';
|
|
8
|
+
import { listenLocal } from '../src/local/serve.mjs';
|
|
9
|
+
import { LocalStore } from '../src/local/store.mjs';
|
|
10
|
+
|
|
11
|
+
async function withServer(fn) {
|
|
12
|
+
const directory = mkdtempSync(join(tmpdir(), 'flambe-local-'));
|
|
13
|
+
const dbPath = join(directory, 'local.sqlite');
|
|
14
|
+
const { server, store, port } = await listenLocal({
|
|
15
|
+
host: '127.0.0.1',
|
|
16
|
+
port: 0,
|
|
17
|
+
dbPath,
|
|
18
|
+
staticDir: null,
|
|
19
|
+
});
|
|
20
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
21
|
+
const { rawToken, traceId } = store.credentials();
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await fn({ origin, rawToken, traceId, store, dbPath, directory });
|
|
25
|
+
} finally {
|
|
26
|
+
server.close();
|
|
27
|
+
store.close();
|
|
28
|
+
rmSync(directory, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('local server binds loopback, seeds a token, and round-trips CLI activity events', async () => {
|
|
33
|
+
await withServer(async ({ origin, rawToken, traceId }) => {
|
|
34
|
+
const health = await fetch(`${origin}/api/health`);
|
|
35
|
+
assert.equal(health.status, 200);
|
|
36
|
+
assert.deepEqual(await health.json(), { status: 'ok' });
|
|
37
|
+
|
|
38
|
+
const headers = {
|
|
39
|
+
authorization: `Bearer ${rawToken}`,
|
|
40
|
+
'content-type': 'application/json',
|
|
41
|
+
'x-flambe-agent-id': 'cursor:test',
|
|
42
|
+
'x-flambe-agent-name': 'Grok',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const begin = await fetch(`${origin}/api/activities`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers,
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
trace_id: traceId,
|
|
50
|
+
thread_id: 1,
|
|
51
|
+
activity: { name: 'Keep work on this laptop' },
|
|
52
|
+
event: { timestamp_integer: 1_788_360_000_123, phase: 'B' },
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
assert.equal(begin.status, 201);
|
|
56
|
+
const created = await begin.json();
|
|
57
|
+
const activityId = created.data.activity.id;
|
|
58
|
+
|
|
59
|
+
const end = await fetch(`${origin}/api/events`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers,
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
trace_id: traceId,
|
|
64
|
+
activity_id: activityId,
|
|
65
|
+
event: { timestamp_integer: 1_788_360_000_999, phase: 'E', message: 'stayed local' },
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
assert.equal(end.status, 201);
|
|
69
|
+
|
|
70
|
+
const trace = await fetch(`${origin}/api/traces/${traceId}`, { headers }).then(r => r.json());
|
|
71
|
+
assert.equal(trace.data.events.length, 2);
|
|
72
|
+
assert.equal(trace.data.events[0].activity.name, 'Keep work on this laptop');
|
|
73
|
+
assert.equal(trace.data.events[0].activity.agent_name, 'Grok');
|
|
74
|
+
assert.equal(trace.data.events[1].phase, 'E');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('login on the local server sets a session cookie and serves the user dashboard', async () => {
|
|
79
|
+
await withServer(async ({ origin }) => {
|
|
80
|
+
const login = await fetch(`${origin}/auth/identity/callback`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'content-type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ email: 'anyone@local', password: 'ignored' }),
|
|
84
|
+
});
|
|
85
|
+
assert.equal(login.status, 200);
|
|
86
|
+
const setCookie = login.headers.get('set-cookie');
|
|
87
|
+
assert.match(setCookie, /flambe_user=1/);
|
|
88
|
+
const { data } = await login.json();
|
|
89
|
+
assert.equal(data.username, 'local');
|
|
90
|
+
|
|
91
|
+
const user = await fetch(`${origin}/api/users/${data.id}`, {
|
|
92
|
+
headers: { cookie: setCookie.split(';')[0] },
|
|
93
|
+
});
|
|
94
|
+
assert.equal(user.status, 200);
|
|
95
|
+
const dashboard = await user.json();
|
|
96
|
+
assert.equal(dashboard.data.traces[0].name, 'Main');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('export writes a remappable bundle without tokens', async () => {
|
|
101
|
+
const directory = mkdtempSync(join(tmpdir(), 'flambe-export-'));
|
|
102
|
+
const dbPath = join(directory, 'local.sqlite');
|
|
103
|
+
const store = new LocalStore(dbPath);
|
|
104
|
+
try {
|
|
105
|
+
store.createActivity(1, {
|
|
106
|
+
traceId: 1,
|
|
107
|
+
threadId: 1,
|
|
108
|
+
activity: { name: 'Company work' },
|
|
109
|
+
event: { timestamp_integer: 1000, phase: 'B' },
|
|
110
|
+
agentId: 'agent-1',
|
|
111
|
+
agentName: 'Grok',
|
|
112
|
+
});
|
|
113
|
+
const out = join(directory, 'out.json');
|
|
114
|
+
const chunks = [];
|
|
115
|
+
await run(['export', out, '--db', dbPath], {
|
|
116
|
+
env: {},
|
|
117
|
+
stdout: { write: chunk => chunks.push(chunk) },
|
|
118
|
+
});
|
|
119
|
+
const bundle = JSON.parse(readFileSync(out, 'utf8'));
|
|
120
|
+
assert.equal(bundle.format, 'flambe-local-export');
|
|
121
|
+
assert.equal(bundle.version, 1);
|
|
122
|
+
assert.equal(bundle.traces[0].activities[0].name, 'Company work');
|
|
123
|
+
assert.ok(bundle.traces[0].export_id);
|
|
124
|
+
assert.equal(JSON.stringify(bundle).includes('flb_'), false);
|
|
125
|
+
} finally {
|
|
126
|
+
store.close();
|
|
127
|
+
rmSync(directory, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('refuses a non-loopback bind', () => {
|
|
132
|
+
assert.throws(
|
|
133
|
+
() => listenLocal({ host: '0.0.0.0', port: 0, dbPath: join(tmpdir(), 'no.sqlite') }),
|
|
134
|
+
/loopback only/,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { FlambeQueue, MAX_FLUSH_ATTEMPTS } from '../src/queue.mjs';
|
|
8
|
+
|
|
9
|
+
const BASE = 'http://flambe.test';
|
|
10
|
+
const TRACE = 3;
|
|
11
|
+
|
|
12
|
+
function seed(entries) {
|
|
13
|
+
const directory = mkdtempSync(join(tmpdir(), 'flambe-queue-'));
|
|
14
|
+
const path = join(directory, 'queue.json');
|
|
15
|
+
writeFileSync(path, JSON.stringify({
|
|
16
|
+
version: 1,
|
|
17
|
+
entries: entries.map(entry => ({ baseUrl: BASE, traceId: TRACE, ...entry })),
|
|
18
|
+
aliases: {},
|
|
19
|
+
}));
|
|
20
|
+
const queue = new FlambeQueue({ baseUrl: BASE, traceId: TRACE, path });
|
|
21
|
+
return { directory, path, queue };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function serverError(status) {
|
|
25
|
+
const error = new Error(`server said ${status}`);
|
|
26
|
+
error.status = status;
|
|
27
|
+
error.retryable = status >= 500;
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function networkError() {
|
|
32
|
+
const error = new TypeError('network unavailable');
|
|
33
|
+
error.retryable = true; // no .status -- server never answered
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function endEntry(activityId) {
|
|
38
|
+
return { type: 'end', activityId, message: `close ${activityId}`, timestamp: 1 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('a permanently 500ing entry is dropped after MAX_FLUSH_ATTEMPTS, unblocking the rest', async () => {
|
|
42
|
+
const { path, queue } = seed([endEntry('217'), endEntry('218')]);
|
|
43
|
+
const warns = [];
|
|
44
|
+
let good = 0;
|
|
45
|
+
const handlers = {
|
|
46
|
+
async end({ activityId }) {
|
|
47
|
+
if (activityId === '217') throw serverError(500); // the varchar(255) case
|
|
48
|
+
good += 1;
|
|
49
|
+
},
|
|
50
|
+
async start() {}, async suspend() {}, async resume() {},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
for (let i = 0; i < MAX_FLUSH_ATTEMPTS; i += 1) {
|
|
55
|
+
await queue.flush(handlers, { warn: message => warns.push(message) });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 217 dropped after the attempt cap; 218 then flushed; queue file removed.
|
|
59
|
+
assert.equal(good, 1);
|
|
60
|
+
assert.equal(existsSync(path), false);
|
|
61
|
+
assert.ok(warns.some(w => /stalled on end \(activity 217\).*attempt 1\//.test(w)));
|
|
62
|
+
assert.ok(warns.some(w => /dropping offline queue entry end \(activity 217\)/.test(w)));
|
|
63
|
+
// the payload is printed so nothing is lost silently
|
|
64
|
+
assert.ok(warns.some(w => w.includes('"activityId":"217"')));
|
|
65
|
+
} finally {
|
|
66
|
+
rmSync(path, { force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an unreachable server never ages out an entry and keeps queue order', async () => {
|
|
71
|
+
const { path, queue } = seed([endEntry('217')]);
|
|
72
|
+
const warns = [];
|
|
73
|
+
const handlers = {
|
|
74
|
+
async end() { throw networkError(); },
|
|
75
|
+
async start() {}, async suspend() {}, async resume() {},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
for (let i = 0; i < MAX_FLUSH_ATTEMPTS + 3; i += 1) {
|
|
80
|
+
await queue.flush(handlers, { warn: message => warns.push(message) });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
84
|
+
assert.equal(state.entries.length, 1); // still there
|
|
85
|
+
assert.equal(state.entries[0].attempts, undefined); // connectivity failures don't count
|
|
86
|
+
assert.ok(warns.every(w => /server unreachable/.test(w)));
|
|
87
|
+
} finally {
|
|
88
|
+
rmSync(path, { force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a 4xx entry is dropped immediately, loudly, and later entries still flush', async () => {
|
|
93
|
+
const { path, queue } = seed([endEntry('217'), endEntry('218')]);
|
|
94
|
+
const warns = [];
|
|
95
|
+
let good = 0;
|
|
96
|
+
const handlers = {
|
|
97
|
+
async end({ activityId }) {
|
|
98
|
+
if (activityId === '217') throw serverError(400);
|
|
99
|
+
good += 1;
|
|
100
|
+
},
|
|
101
|
+
async start() {}, async suspend() {}, async resume() {},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await queue.flush(handlers, { warn: message => warns.push(message) }); // a single pass suffices
|
|
106
|
+
|
|
107
|
+
assert.equal(good, 1);
|
|
108
|
+
assert.equal(existsSync(path), false);
|
|
109
|
+
assert.ok(warns.some(w => /dropping offline queue entry end \(activity 217\) after 1 attempt/.test(w)));
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(path, { force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('entries for a different server are left untouched', async () => {
|
|
116
|
+
const { path, queue } = seed([{ type: 'end', activityId: '9', message: 'x', timestamp: 1 }]);
|
|
117
|
+
// Rewrite the one entry to belong to another base URL.
|
|
118
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
119
|
+
state.entries[0].baseUrl = 'http://other.test';
|
|
120
|
+
writeFileSync(path, JSON.stringify(state));
|
|
121
|
+
|
|
122
|
+
const handlers = {
|
|
123
|
+
async end() { throw new Error('should not be called'); },
|
|
124
|
+
async start() {}, async suspend() {}, async resume() {},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await queue.flush(handlers, { warn() {} });
|
|
129
|
+
const after = JSON.parse(readFileSync(path, 'utf8'));
|
|
130
|
+
assert.equal(after.entries.length, 1);
|
|
131
|
+
assert.equal(after.entries[0].baseUrl, 'http://other.test');
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(path, { force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|