@davvidbaker/flambe-cli 0.1.0 → 0.1.2
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/README.md +63 -0
- package/package.json +17 -3
- package/src/agentNames.mjs +35 -0
- package/src/cli.mjs +124 -10
- package/src/client.mjs +359 -41
- package/src/local/agents.mjs +18 -12
- package/src/local/commands.mjs +291 -0
- package/src/local/http.mjs +116 -20
- package/src/local/serve.mjs +2 -1
- package/src/local/store.mjs +262 -39
- package/src/questions.mjs +48 -0
- package/src/queue.mjs +15 -3
- package/src/runtime.mjs +23 -0
- package/test/client.test.mjs +0 -951
- package/test/local.test.mjs +0 -136
- package/test/queue.test.mjs +0 -135
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Local start/end/suspend/resume rules. HTTP is the adapter; SQLite is the source of truth.
|
|
2
|
+
export const COMMANDS = ['start', 'end', 'suspend', 'resume', 'status', 'message', 'plan'];
|
|
3
|
+
const RUNNING = new Set(['B', 'R', 'X']);
|
|
4
|
+
const ENDED = new Set(['E', 'J', 'V']);
|
|
5
|
+
const LIFECYCLE = new Set([...RUNNING, ...ENDED, 'S']);
|
|
6
|
+
const STOPWORDS = new Set('the and for with from into that this work task item stuff'.split(' '));
|
|
7
|
+
const tokens = name => name.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length >= 4 && !STOPWORDS.has(t));
|
|
8
|
+
|
|
9
|
+
export class CommandError extends Error {
|
|
10
|
+
constructor(status, code, message, extra = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.body = { error: { code, message, ...extra } };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function invalid(message) { throw new CommandError(422, 'INVALID_INPUT', message); }
|
|
17
|
+
function notFound() { throw new CommandError(404, 'NOT_FOUND', 'The requested resource was not found'); }
|
|
18
|
+
function positiveId(value, field) {
|
|
19
|
+
if (!['number', 'string'].includes(typeof value) || !/^\d+$/.test(String(value))
|
|
20
|
+
|| !Number.isSafeInteger(Number(value)) || Number(value) <= 0) invalid(`${field} must be a positive integer`);
|
|
21
|
+
return Number(value);
|
|
22
|
+
}
|
|
23
|
+
function optionalId(value, field) { return value == null ? null : positiveId(value, field); }
|
|
24
|
+
function bool(value, field) {
|
|
25
|
+
if (value !== undefined && typeof value !== 'boolean') invalid(`${field} must be boolean`);
|
|
26
|
+
return value ?? false;
|
|
27
|
+
}
|
|
28
|
+
function requiredText(value, field) {
|
|
29
|
+
if (typeof value !== 'string' || !value.trim()) invalid(`${field} is required`);
|
|
30
|
+
return value.trim();
|
|
31
|
+
}
|
|
32
|
+
function compare(a, b) { return Date.parse(a.timestamp) - Date.parse(b.timestamp) || a.id - b.id; }
|
|
33
|
+
|
|
34
|
+
/** Derive state only from lifecycle history; annotations never reopen work. */
|
|
35
|
+
export function commandState(trace, filters = {}) {
|
|
36
|
+
const latest = new Map();
|
|
37
|
+
const begins = new Map();
|
|
38
|
+
for (const event of trace.events) {
|
|
39
|
+
if (!event.activity || !LIFECYCLE.has(event.phase)) continue;
|
|
40
|
+
const id = event.activity.id;
|
|
41
|
+
if (!latest.has(id) || compare(event, latest.get(id)) > 0) latest.set(id, event);
|
|
42
|
+
if (event.phase === 'B' && (!begins.has(id) || compare(event, begins.get(id)) < 0)) begins.set(id, event);
|
|
43
|
+
}
|
|
44
|
+
const chain = id => {
|
|
45
|
+
const result = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
while (latest.has(id) && !seen.has(id)) {
|
|
48
|
+
seen.add(id);
|
|
49
|
+
const event = latest.get(id);
|
|
50
|
+
result.unshift(event);
|
|
51
|
+
id = event.activity.parent_id;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
const threads = trace.threads.map(thread => ({ ...thread, availableActions: [{
|
|
56
|
+
operation: 'start', arguments: { trace_id: trace.id, thread_id: thread.id, parent_id: null },
|
|
57
|
+
}] }));
|
|
58
|
+
const activities = [...latest.values()].sort(compare).map(event => {
|
|
59
|
+
const activity = event.activity;
|
|
60
|
+
const ancestors = chain(activity.id);
|
|
61
|
+
const status = RUNNING.has(event.phase)
|
|
62
|
+
? ancestors.slice(0, -1).some(a => a.phase === 'S') ? 'parent_suspended' : 'running'
|
|
63
|
+
: event.phase === 'S' ? 'suspended' : 'ended';
|
|
64
|
+
const descendants = [...latest.values()].filter(e => e.activity.id !== activity.id
|
|
65
|
+
&& RUNNING.has(e.phase) && chain(e.activity.id).some(a => a.activity.id === activity.id));
|
|
66
|
+
const args = { trace_id: trace.id, activity_id: activity.id };
|
|
67
|
+
const end = { operation: 'end', arguments: args, ...(descendants.length ? {
|
|
68
|
+
requires: { force: true }, reason: 'open_descendants',
|
|
69
|
+
} : {}) };
|
|
70
|
+
const actions = status === 'running' ? [
|
|
71
|
+
{ operation: 'start', arguments: { trace_id: trace.id, thread_id: activity.thread.id, parent_id: activity.id } },
|
|
72
|
+
{ operation: 'suspend', arguments: args }, end,
|
|
73
|
+
] : status === 'suspended' ? [{ operation: 'resume', arguments: args }, end] : [];
|
|
74
|
+
return {
|
|
75
|
+
id: activity.id, name: activity.name, threadId: activity.thread.id,
|
|
76
|
+
threadName: threads.find(t => t.id === activity.thread.id)?.name ?? null,
|
|
77
|
+
parentId: activity.parent_id ?? null, agentId: activity.agent_id ?? null,
|
|
78
|
+
path: ancestors.map(e => e.activity.name), categoryIds: activity.categories ?? [],
|
|
79
|
+
startedAt: begins.get(activity.id)?.timestamp ?? null,
|
|
80
|
+
latestEvent: { id: event.id, phase: event.phase, timestamp: event.timestamp,
|
|
81
|
+
...(event.message ? { message: event.message } : {}) },
|
|
82
|
+
status, availableActions: actions,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
trace: { id: trace.id, name: trace.name },
|
|
87
|
+
threads: threads.filter(t => !filters.thread_id || t.id === filters.thread_id),
|
|
88
|
+
activities: activities.filter(a => (!filters.thread_id || a.threadId === filters.thread_id)
|
|
89
|
+
&& (!filters.active_only || a.status === 'running')
|
|
90
|
+
&& (!filters.suspended_only || ['suspended', 'parent_suspended'].includes(a.status))),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A command and all of its corrections commit together, or not at all. */
|
|
95
|
+
export function executeCommand(store, userId, command, attrs, headerAgent = null) {
|
|
96
|
+
if (!COMMANDS.includes(command) || !attrs || typeof attrs !== 'object' || Array.isArray(attrs)) {
|
|
97
|
+
invalid('command and arguments are required');
|
|
98
|
+
}
|
|
99
|
+
const traceId = positiveId(attrs.trace_id, 'trace_id');
|
|
100
|
+
const trace = store.getTrace(userId, traceId);
|
|
101
|
+
if (!trace) notFound();
|
|
102
|
+
const before = commandState(trace);
|
|
103
|
+
const byId = new Map(before.activities.map(a => [a.id, a]));
|
|
104
|
+
const timestamp = attrs.timestamp ?? Date.now();
|
|
105
|
+
if (!Number.isSafeInteger(timestamp) || !Number.isFinite(new Date(timestamp).getTime())) invalid('timestamp must be milliseconds since epoch');
|
|
106
|
+
const threadId = optionalId(attrs.thread_id, 'thread_id');
|
|
107
|
+
// Starts resolve the parent's lane even if a different lane was requested.
|
|
108
|
+
if (command === 'status' && threadId && !trace.threads.some(t => t.id === threadId)) notFound();
|
|
109
|
+
const filters = { thread_id: threadId,
|
|
110
|
+
active_only: bool(attrs.active_only, 'active_only'),
|
|
111
|
+
suspended_only: bool(attrs.suspended_only, 'suspended_only'),
|
|
112
|
+
include_unstarted: bool(attrs.include_unstarted, 'include_unstarted') };
|
|
113
|
+
if (filters.active_only && filters.suspended_only) invalid('active_only and suspended_only cannot both be true');
|
|
114
|
+
const result = { direction: null, reply: null, actions_applied: [], rules_fired: [], closed_descendants: [] };
|
|
115
|
+
if (command === 'status') {
|
|
116
|
+
const state = commandState(trace, filters);
|
|
117
|
+
if (filters.include_unstarted && !filters.active_only && !filters.suspended_only) {
|
|
118
|
+
for (const row of store.listUnstarted(traceId)) {
|
|
119
|
+
if (filters.thread_id && row.thread_id !== filters.thread_id) continue;
|
|
120
|
+
state.activities.push({
|
|
121
|
+
id: row.id, name: row.name, threadId: row.thread_id,
|
|
122
|
+
threadName: trace.threads.find(t => t.id === row.thread_id)?.name ?? null,
|
|
123
|
+
parentId: row.parent_id, agentId: row.agent_id, path: [row.name],
|
|
124
|
+
categoryIds: [], startedAt: null, latestEvent: null, status: 'unstarted',
|
|
125
|
+
scheduledStart: row.scheduled_start_ms, scheduledEnd: row.scheduled_end_ms,
|
|
126
|
+
availableActions: [{ operation: 'start', arguments: { trace_id: trace.id, activity_id: row.id, name: row.name } }],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { ...result, state };
|
|
131
|
+
}
|
|
132
|
+
if (command === 'plan') {
|
|
133
|
+
const name = requiredText(attrs.name, 'name');
|
|
134
|
+
if (name.length > 255) invalid('name must be at most 255 characters');
|
|
135
|
+
const startAt = attrs.scheduled_start ?? null;
|
|
136
|
+
const endAt = attrs.scheduled_end ?? null;
|
|
137
|
+
if (startAt != null && (!Number.isSafeInteger(startAt))) invalid('scheduled_start must be milliseconds');
|
|
138
|
+
if (endAt != null && (!Number.isSafeInteger(endAt))) invalid('scheduled_end must be milliseconds');
|
|
139
|
+
if (startAt != null && endAt != null && endAt < startAt) invalid('scheduled_end must be at or after scheduled_start');
|
|
140
|
+
const created = store.createUnstarted(userId, {
|
|
141
|
+
traceId, name, description: attrs.description, weight: attrs.weight,
|
|
142
|
+
scheduledStart: startAt, scheduledEnd: endAt,
|
|
143
|
+
agent: headerAgent ?? (attrs.agent_id ? { agent_id: attrs.agent_id, name: attrs.agent_name } : null),
|
|
144
|
+
});
|
|
145
|
+
if (created.error) notFound();
|
|
146
|
+
result.activity_id = created.activity.id;
|
|
147
|
+
return { ...result, state: commandState(store.getTrace(userId, traceId), filters) };
|
|
148
|
+
}
|
|
149
|
+
if (command === 'message') {
|
|
150
|
+
requiredText(attrs.message, 'message');
|
|
151
|
+
if (attrs.activity_id != null && !byId.has(positiveId(attrs.activity_id, 'activity_id'))) notFound();
|
|
152
|
+
throw new CommandError(503, 'REDUCER_NOT_CONFIGURED',
|
|
153
|
+
'Model review is unavailable in the self-contained runtime. Lifecycle commands remain available; no content was sent to a provider.');
|
|
154
|
+
}
|
|
155
|
+
if (attrs.message != null && typeof attrs.message !== 'string') invalid('message must be a string');
|
|
156
|
+
if (attrs.description != null && typeof attrs.description !== 'string') invalid('description must be a string');
|
|
157
|
+
const force = bool(attrs.force, 'force');
|
|
158
|
+
const agentId = headerAgent?.agent_id ?? attrs.agent_id;
|
|
159
|
+
if (agentId != null && (typeof agentId !== 'string' || !agentId.trim() || agentId.length > 200)) invalid('agent_id is invalid');
|
|
160
|
+
|
|
161
|
+
const ancestors = activity => {
|
|
162
|
+
const chain = [];
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
while (activity && !seen.has(activity.id)) {
|
|
165
|
+
seen.add(activity.id);
|
|
166
|
+
chain.unshift(activity);
|
|
167
|
+
activity = byId.get(activity.parentId);
|
|
168
|
+
}
|
|
169
|
+
return chain;
|
|
170
|
+
};
|
|
171
|
+
const record = (id, phase, message) => {
|
|
172
|
+
const event = store.createEvent(userId, { traceId, activityId: id,
|
|
173
|
+
event: { phase, message, timestamp_integer: timestamp } });
|
|
174
|
+
if (event.error) notFound();
|
|
175
|
+
return event;
|
|
176
|
+
};
|
|
177
|
+
const annotate = (id, rule, applied) => {
|
|
178
|
+
result.rules_fired.push({ rule, applied });
|
|
179
|
+
record(id, 'reducer_decision', `rule=${rule} | applied=${JSON.stringify(applied)}`);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
store.db.exec('BEGIN IMMEDIATE');
|
|
183
|
+
try {
|
|
184
|
+
const agent = headerAgent ?? (agentId ? store.identifyAgent(userId, agentId, attrs.agent_name, attrs.agent_platform) : null);
|
|
185
|
+
if (command === 'start') {
|
|
186
|
+
const name = requiredText(attrs.name, 'name');
|
|
187
|
+
if (name.length > 255) invalid('name must be at most 255 characters');
|
|
188
|
+
const categories = attrs.category_ids ?? [];
|
|
189
|
+
if (!Array.isArray(categories)) invalid('category_ids must be an array');
|
|
190
|
+
const categoryIds = [...new Set(categories.map(id => positiveId(id, 'category_id')))];
|
|
191
|
+
const explicitParent = Object.hasOwn(attrs, 'parent_id');
|
|
192
|
+
const requestedParent = optionalId(attrs.parent_id, 'parent_id');
|
|
193
|
+
if (requestedParent && !byId.has(requestedParent)) notFound();
|
|
194
|
+
const unstartedRows = store.listUnstarted(traceId);
|
|
195
|
+
const beginId = optionalId(attrs.activity_id, 'activity_id');
|
|
196
|
+
const namedLimbo = agentId && unstartedRows.find(row => row.proposed_by_agent_id === agentId
|
|
197
|
+
&& row.name === name && row.scheduled_start_ms == null && row.scheduled_end_ms == null);
|
|
198
|
+
const explicit = beginId && unstartedRows.find(row => row.id === beginId);
|
|
199
|
+
if (beginId && !explicit) notFound();
|
|
200
|
+
const toBegin = explicit || namedLimbo;
|
|
201
|
+
if (toBegin) {
|
|
202
|
+
if (toBegin.parent_id) {
|
|
203
|
+
const parent = unstartedRows.find(row => row.id === toBegin.parent_id);
|
|
204
|
+
if (parent) invalid('parent is unstarted');
|
|
205
|
+
}
|
|
206
|
+
if (agentId) {
|
|
207
|
+
store.db.prepare('UPDATE activities SET agent_id = ?, agent_name = ? WHERE id = ?')
|
|
208
|
+
.run(agentId, headerAgent?.name ?? attrs.agent_name ?? agentId, toBegin.id);
|
|
209
|
+
}
|
|
210
|
+
const event = record(toBegin.id, 'B', 'Begun by reducer: a start matched this activity in limbo');
|
|
211
|
+
result.activity_id = toBegin.id;
|
|
212
|
+
result.event_id = event.id;
|
|
213
|
+
const action = { type: 'begin_existing', activity_id: toBegin.id };
|
|
214
|
+
result.actions_applied.push(action);
|
|
215
|
+
annotate(toBegin.id, 'begin_limbo', action);
|
|
216
|
+
} else {
|
|
217
|
+
const duplicate = agentId && [...before.activities].reverse().find(a => a.agentId === agentId
|
|
218
|
+
&& a.name === name && !ENDED.has(a.latestEvent.phase));
|
|
219
|
+
if (duplicate) {
|
|
220
|
+
result.activity_id = duplicate.id;
|
|
221
|
+
const type = duplicate.latestEvent.phase === 'S' ? 'resume_existing' : 'no_op';
|
|
222
|
+
result.event_id = type === 'resume_existing'
|
|
223
|
+
? record(duplicate.id, 'R', 'Resumed by reducer: duplicate start').id : duplicate.latestEvent.id;
|
|
224
|
+
const action = { type, activity_id: duplicate.id };
|
|
225
|
+
result.actions_applied.push(action);
|
|
226
|
+
annotate(duplicate.id, 'duplicate_open', action);
|
|
227
|
+
} else {
|
|
228
|
+
const parent = explicitParent ? byId.get(requestedParent) : [...before.activities].reverse()
|
|
229
|
+
.find(a => a.status === 'running' && (agentId ? a.agentId === agentId : a.threadId === (threadId ?? trace.threads[0]?.id)));
|
|
230
|
+
for (const ancestor of ancestors(parent)) {
|
|
231
|
+
if (ancestor.latestEvent.phase !== 'S') continue;
|
|
232
|
+
record(ancestor.id, 'R', 'Resumed by reducer: child started under suspended work');
|
|
233
|
+
result.actions_applied.push({ type: 'resume_ancestor', activity_id: ancestor.id });
|
|
234
|
+
}
|
|
235
|
+
const created = store.createActivity(userId, {
|
|
236
|
+
traceId, threadId, agent, reduce: true,
|
|
237
|
+
activity: { name, description: attrs.description, categories: categoryIds,
|
|
238
|
+
parent_id: parent?.id ?? null },
|
|
239
|
+
event: { phase: 'B', timestamp_integer: timestamp },
|
|
240
|
+
});
|
|
241
|
+
if (created.error) notFound();
|
|
242
|
+
result.activity_id = created.activity.id;
|
|
243
|
+
result.event_id = created.event.id;
|
|
244
|
+
result.reducer = { ...created.reducer,
|
|
245
|
+
parent_source: parent ? explicitParent ? 'explicit' : 'inferred' : 'root' };
|
|
246
|
+
if (result.actions_applied.length) annotate(result.activity_id, 'resume_ancestor',
|
|
247
|
+
result.actions_applied.map(a => a.activity_id));
|
|
248
|
+
if (!parent && agentId && before.activities.some(a => a.agentId === agentId && a.status === 'running')) {
|
|
249
|
+
annotate(result.activity_id, 'new_root_while_open', { type: 'skipped', reason: 'model_unavailable' });
|
|
250
|
+
} else if (parent) {
|
|
251
|
+
const ancestorTokens = new Set(ancestors(parent).flatMap(a => tokens(a.name)));
|
|
252
|
+
const childTokens = tokens(name);
|
|
253
|
+
if (childTokens.length && childTokens.every(t => !ancestorTokens.has(t))) {
|
|
254
|
+
annotate(result.activity_id, 'name_unfit', { type: 'skipped', reason: 'model_unavailable' });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
const id = positiveId(attrs.activity_id, 'activity_id');
|
|
261
|
+
const activity = byId.get(id);
|
|
262
|
+
if (!activity) notFound();
|
|
263
|
+
const phase = command === 'end' ? attrs.phase ?? (attrs.message ? 'V' : 'E') : command === 'resume' ? 'R' : 'S';
|
|
264
|
+
if (command === 'end' && !ENDED.has(phase)) invalid('end phase must be E, J, or V');
|
|
265
|
+
const descendants = before.activities.filter(a => a.id !== id && RUNNING.has(a.latestEvent.phase)
|
|
266
|
+
&& ancestors(a).some(p => p.id === id)).sort((a, b) => b.path.length - a.path.length || b.id - a.id);
|
|
267
|
+
if (command === 'end' && descendants.length && !force) {
|
|
268
|
+
throw new CommandError(409, 'OPEN_CHILDREN', 'End descendants first or use force: true', {
|
|
269
|
+
open_children: descendants.map(a => ({ activity_id: a.id, activity_name: a.name })),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (command === 'end') {
|
|
273
|
+
for (const child of descendants) {
|
|
274
|
+
const event = record(child.id, 'E', `Ended by reducer: parent activity ${id} (${activity.name}) ended`);
|
|
275
|
+
result.closed_descendants.push({ activity_id: child.id, activity_name: child.name, event_id: event.id });
|
|
276
|
+
}
|
|
277
|
+
if (!descendants.length && activity.parentId != null) result.rules_fired.push({
|
|
278
|
+
rule: 'pop_to_parent', applied: { activity_id: activity.parentId, name: byId.get(activity.parentId)?.name },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
result.activity_id = id;
|
|
282
|
+
result.event_id = record(id, phase, attrs.message).id;
|
|
283
|
+
}
|
|
284
|
+
result.state = commandState(store.getTrace(userId, traceId));
|
|
285
|
+
store.db.exec('COMMIT');
|
|
286
|
+
return result;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
store.db.exec('ROLLBACK');
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
package/src/local/http.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { extname, join, normalize, resolve } from 'node:path';
|
|
3
|
+
import { COMMANDS, CommandError, executeCommand } from './commands.mjs';
|
|
3
4
|
|
|
4
5
|
function sendJson(res, status, body) {
|
|
5
6
|
const payload = JSON.stringify(body);
|
|
@@ -10,8 +11,8 @@ function sendJson(res, status, body) {
|
|
|
10
11
|
res.end(payload);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
function sendEmpty(res, status) {
|
|
14
|
-
res.writeHead(status);
|
|
14
|
+
function sendEmpty(res, status, headers = {}) {
|
|
15
|
+
res.writeHead(status, headers);
|
|
15
16
|
res.end();
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -88,12 +89,32 @@ function headerBag() {
|
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const auth = authenticate(store, req);
|
|
92
|
+
// Same contract as FlambeNextWeb.Plugs.AgentIdentity (ADR-012).
|
|
93
|
+
function identifyAgent(store, req, auth) {
|
|
94
|
+
if (!auth) return null;
|
|
95
95
|
const agentId = req.headers['x-flambe-agent-id'];
|
|
96
96
|
const agentName = req.headers['x-flambe-agent-name'];
|
|
97
|
+
const agentPlatform = req.headers['x-flambe-agent-platform'];
|
|
98
|
+
if (typeof agentId !== 'string') return null;
|
|
99
|
+
return store.identifyAgent(
|
|
100
|
+
auth.user.id,
|
|
101
|
+
agentId,
|
|
102
|
+
typeof agentName === 'string' ? agentName : undefined,
|
|
103
|
+
typeof agentPlatform === 'string' ? agentPlatform : undefined,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function agentHeaders(agent) {
|
|
108
|
+
if (!agent) return {};
|
|
109
|
+
return {
|
|
110
|
+
'x-flambe-agent-name': agent.name,
|
|
111
|
+
...(agent.assigned ? { 'x-flambe-agent-name-assigned': 'true' } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function dispatch(store, req, url, body, { auth, agent }) {
|
|
116
|
+
const method = req.method ?? 'GET';
|
|
117
|
+
const path = url.pathname;
|
|
97
118
|
|
|
98
119
|
if (method === 'GET' && path === '/api/health') {
|
|
99
120
|
return { status: 200, json: { status: 'ok' } };
|
|
@@ -123,6 +144,22 @@ async function dispatch(store, req, url, body) {
|
|
|
123
144
|
|
|
124
145
|
const userId = auth?.user?.id;
|
|
125
146
|
|
|
147
|
+
if (path === '/api/agent-commands') {
|
|
148
|
+
if (method === 'GET') return { status: 200, json: { data: {
|
|
149
|
+
version: 1, commands: COMMANDS, runtime_mode: 'local_self_contained', model_review: false,
|
|
150
|
+
} } };
|
|
151
|
+
if (method === 'POST') return { status: 200, json: {
|
|
152
|
+
data: executeCommand(store, userId, body.command, body.arguments, agent),
|
|
153
|
+
} };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (method === 'GET' && path === '/api/agents/me') {
|
|
157
|
+
if (!agent) {
|
|
158
|
+
return { status: 400, json: { error: 'AGENT_ID_REQUIRED', detail: 'Send x-flambe-agent-id with a bearer token' } };
|
|
159
|
+
}
|
|
160
|
+
return { status: 200, json: { data: { agent_id: agent.agent_id, name: agent.name, platform: agent.platform ?? null, name_assigned: agent.assigned } } };
|
|
161
|
+
}
|
|
162
|
+
|
|
126
163
|
if (method === 'GET' && path === '/api/export') {
|
|
127
164
|
return { status: 200, json: store.exportBundle() };
|
|
128
165
|
}
|
|
@@ -220,14 +257,29 @@ async function dispatch(store, req, url, body) {
|
|
|
220
257
|
}
|
|
221
258
|
|
|
222
259
|
if (method === 'POST' && path === '/api/activities') {
|
|
260
|
+
if (auth.tokenName !== null) {
|
|
261
|
+
const activity = body.activity ?? {};
|
|
262
|
+
const folded = executeCommand(store, userId, 'start', {
|
|
263
|
+
trace_id: body.trace_id, thread_id: body.thread_id, name: activity.name,
|
|
264
|
+
description: activity.description, category_ids: activity.categories,
|
|
265
|
+
...(Object.hasOwn(activity, 'parent_id') ? { parent_id: activity.parent_id } : {}),
|
|
266
|
+
timestamp: body.event?.timestamp_integer,
|
|
267
|
+
}, agent);
|
|
268
|
+
return { status: 201, json: { data: {
|
|
269
|
+
activity: store.getActivity(userId, folded.activity_id),
|
|
270
|
+
event: { id: folded.event_id,
|
|
271
|
+
phase: folded.state.activities.find(a => a.id === folded.activity_id)?.latestEvent.phase },
|
|
272
|
+
reducer: folded.reducer ?? { actions_applied: folded.actions_applied },
|
|
273
|
+
direction: folded.direction, reply: folded.reply, rules_fired: folded.rules_fired,
|
|
274
|
+
actions_applied: folded.actions_applied,
|
|
275
|
+
} } };
|
|
276
|
+
}
|
|
223
277
|
const result = store.createActivity(userId, {
|
|
224
278
|
traceId: body.trace_id,
|
|
225
279
|
threadId: body.thread_id,
|
|
226
280
|
activity: body.activity ?? {},
|
|
227
281
|
event: body.event ?? {},
|
|
228
|
-
|
|
229
|
-
agentName: typeof agentName === 'string' ? agentName : undefined,
|
|
230
|
-
tokenName: auth.tokenName,
|
|
282
|
+
agent,
|
|
231
283
|
});
|
|
232
284
|
if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
|
|
233
285
|
return { status: 201, json: { data: result } };
|
|
@@ -242,7 +294,14 @@ async function dispatch(store, req, url, body) {
|
|
|
242
294
|
return { status: 200, json: { data: activity } };
|
|
243
295
|
}
|
|
244
296
|
if (method === 'PUT') {
|
|
245
|
-
|
|
297
|
+
let attrs = body.activity ?? {};
|
|
298
|
+
// Same rule as Phoenix: bearer tokens cannot reassign identity; a session can.
|
|
299
|
+
if (auth.tokenName !== null) {
|
|
300
|
+
attrs = Object.fromEntries(
|
|
301
|
+
Object.entries(attrs).filter(([key]) => key !== 'agent_id' && key !== 'agent_name'),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const activity = store.updateActivity(userId, id, attrs);
|
|
246
305
|
if (activity?.error === 'not_found' || !activity) return { status: 404, json: { error: 'NOT_FOUND' } };
|
|
247
306
|
return { status: 200, json: { data: activity } };
|
|
248
307
|
}
|
|
@@ -253,6 +312,21 @@ async function dispatch(store, req, url, body) {
|
|
|
253
312
|
}
|
|
254
313
|
|
|
255
314
|
if (method === 'POST' && path === '/api/events') {
|
|
315
|
+
const phase = body.event?.phase;
|
|
316
|
+
const command = { E: 'end', J: 'end', V: 'end', S: 'suspend', R: 'resume' }[phase];
|
|
317
|
+
if (auth.tokenName !== null && command) {
|
|
318
|
+
const folded = executeCommand(store, userId, command, {
|
|
319
|
+
trace_id: body.trace_id, activity_id: body.activity_id,
|
|
320
|
+
timestamp: body.event?.timestamp_integer, message: body.event?.message,
|
|
321
|
+
phase, force: body.force ?? true,
|
|
322
|
+
}, agent);
|
|
323
|
+
return { status: 201, json: { data: {
|
|
324
|
+
id: folded.event_id, phase,
|
|
325
|
+
reducer: { closed_descendants: folded.closed_descendants,
|
|
326
|
+
rules_fired: folded.rules_fired, actions_applied: folded.actions_applied,
|
|
327
|
+
direction: folded.direction, reply: folded.reply },
|
|
328
|
+
} } };
|
|
329
|
+
}
|
|
256
330
|
const result = store.createEvent(userId, {
|
|
257
331
|
traceId: body.trace_id,
|
|
258
332
|
activityId: body.activity_id,
|
|
@@ -282,10 +356,6 @@ async function dispatch(store, req, url, body) {
|
|
|
282
356
|
return { status: 200, json: { data: store.userDashboard(userId) } };
|
|
283
357
|
}
|
|
284
358
|
|
|
285
|
-
if (method === 'POST' && path === '/api/todos') {
|
|
286
|
-
return { status: 201, json: { data: store.createTodo(userId, body.todo ?? {}) } };
|
|
287
|
-
}
|
|
288
|
-
|
|
289
359
|
if (method === 'POST' && path === '/api/mantras') {
|
|
290
360
|
return { status: 201, json: { data: store.createMantra(userId, body.mantra ?? {}) } };
|
|
291
361
|
}
|
|
@@ -327,7 +397,8 @@ function isoNow() {
|
|
|
327
397
|
function tryStatic(staticDir, urlPath, res) {
|
|
328
398
|
if (!staticDir) return false;
|
|
329
399
|
const root = resolve(staticDir);
|
|
330
|
-
const
|
|
400
|
+
const rootIndex = join(root, 'index.html');
|
|
401
|
+
const indexPath = existsSync(rootIndex) ? rootIndex : join(root, 'assets', 'index.html');
|
|
331
402
|
const requested = urlPath === '/' || !urlPath.includes('.')
|
|
332
403
|
? indexPath
|
|
333
404
|
: join(root, urlPath.replace(/^\//, ''));
|
|
@@ -349,6 +420,21 @@ function tryStatic(staticDir, urlPath, res) {
|
|
|
349
420
|
export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
|
|
350
421
|
const host = req.headers.host ?? '127.0.0.1';
|
|
351
422
|
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
423
|
+
// A locally served UI must not send trace content to a compiled-in remote API.
|
|
424
|
+
res.setHeader('content-security-policy', "connect-src 'self'; form-action 'self'; frame-ancestors 'none'");
|
|
425
|
+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) {
|
|
426
|
+
sendJson(res, 403, { error: 'LOCAL_HOST_REQUIRED' });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (req.headers.origin) {
|
|
430
|
+
let origin;
|
|
431
|
+
try { origin = new URL(req.headers.origin); } catch { /* rejected below */ }
|
|
432
|
+
if (!origin || !['http:', 'https:'].includes(origin.protocol)
|
|
433
|
+
|| !['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname)) {
|
|
434
|
+
sendJson(res, 403, { error: 'LOCAL_ORIGIN_REQUIRED' });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
352
438
|
|
|
353
439
|
if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
|
|
354
440
|
if (tryStatic(staticDir, url.pathname, res)) return;
|
|
@@ -356,8 +442,9 @@ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
|
|
|
356
442
|
const html = `<!doctype html><meta charset="utf-8"><title>Flambe local</title>
|
|
357
443
|
<body style="font-family:sans-serif;max-width:40rem;margin:3rem auto;line-height:1.4">
|
|
358
444
|
<h1>Flambe local</h1>
|
|
359
|
-
<p>CLI API is running.
|
|
445
|
+
<p>CLI API is running. From the repository root, run <code>npm --prefix frontend run build:local</code>, then restart <code>flambe serve</code> to open the local chart here.</p>
|
|
360
446
|
<pre>FLAMBE_URL=http://${host}
|
|
447
|
+
FLAMBE_RUNTIME_MODE=local_self_contained
|
|
361
448
|
FLAMBE_API_TOKEN=${rawToken}
|
|
362
449
|
FLAMBE_TRACE_ID=${traceId}</pre>
|
|
363
450
|
</body>`;
|
|
@@ -374,15 +461,24 @@ FLAMBE_TRACE_ID=${traceId}</pre>
|
|
|
374
461
|
return;
|
|
375
462
|
}
|
|
376
463
|
|
|
377
|
-
const
|
|
464
|
+
const auth = authenticate(store, req);
|
|
465
|
+
const agent = identifyAgent(store, req, auth);
|
|
466
|
+
let result;
|
|
467
|
+
try {
|
|
468
|
+
result = await dispatch(store, req, url, body, { auth, agent });
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (!(error instanceof CommandError)) throw error;
|
|
471
|
+
result = { status: error.status, json: error.body };
|
|
472
|
+
}
|
|
473
|
+
const identity = agentHeaders(agent);
|
|
378
474
|
if (result.status === 204) {
|
|
379
|
-
if (result.headers) result.headers.apply(res, 204);
|
|
380
|
-
else sendEmpty(res, 204);
|
|
475
|
+
if (result.headers) result.headers.apply(res, 204, identity);
|
|
476
|
+
else sendEmpty(res, 204, identity);
|
|
381
477
|
return;
|
|
382
478
|
}
|
|
383
479
|
|
|
384
480
|
const payload = JSON.stringify(result.json);
|
|
385
|
-
const extra = { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload) };
|
|
481
|
+
const extra = { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload), ...identity };
|
|
386
482
|
if (result.headers) result.headers.apply(res, result.status, extra);
|
|
387
483
|
else res.writeHead(result.status, extra);
|
|
388
484
|
res.end(payload);
|
package/src/local/serve.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export function defaultDbPath() {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function defaultStaticDir() {
|
|
15
|
-
return resolve(fileURLToPath(new URL('
|
|
15
|
+
return resolve(fileURLToPath(new URL('../../../backend/priv/static', import.meta.url)));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function createLocalServer({ dbPath = defaultDbPath(), staticDir } = {}) {
|
|
@@ -56,6 +56,7 @@ export function printServeBanner({ host, port, store }, stdout = process.stdout)
|
|
|
56
56
|
const origin = `http://${host}:${port}`;
|
|
57
57
|
stdout.write(`Flambe local on ${origin} (SQLite, loopback only)\n`);
|
|
58
58
|
stdout.write(`FLAMBE_URL=${origin}\n`);
|
|
59
|
+
stdout.write('FLAMBE_RUNTIME_MODE=local_self_contained\n');
|
|
59
60
|
stdout.write(`FLAMBE_API_TOKEN=${rawToken}\n`);
|
|
60
61
|
stdout.write(`FLAMBE_TRACE_ID=${traceId}\n`);
|
|
61
62
|
}
|