@davvidbaker/flambe-cli 0.1.1 → 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 +4 -2
- package/package.json +1 -1
- package/src/cli.mjs +90 -7
- package/src/client.mjs +209 -17
- package/src/local/commands.mjs +291 -0
- package/src/local/http.mjs +76 -10
- package/src/local/serve.mjs +2 -1
- package/src/local/store.mjs +117 -31
- package/src/questions.mjs +48 -0
- package/src/queue.mjs +15 -3
- package/src/runtime.mjs +23 -0
|
@@ -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);
|
|
@@ -143,6 +144,15 @@ async function dispatch(store, req, url, body, { auth, agent }) {
|
|
|
143
144
|
|
|
144
145
|
const userId = auth?.user?.id;
|
|
145
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
|
+
|
|
146
156
|
if (method === 'GET' && path === '/api/agents/me') {
|
|
147
157
|
if (!agent) {
|
|
148
158
|
return { status: 400, json: { error: 'AGENT_ID_REQUIRED', detail: 'Send x-flambe-agent-id with a bearer token' } };
|
|
@@ -247,14 +257,29 @@ async function dispatch(store, req, url, body, { auth, agent }) {
|
|
|
247
257
|
}
|
|
248
258
|
|
|
249
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
|
+
}
|
|
250
277
|
const result = store.createActivity(userId, {
|
|
251
278
|
traceId: body.trace_id,
|
|
252
279
|
threadId: body.thread_id,
|
|
253
280
|
activity: body.activity ?? {},
|
|
254
281
|
event: body.event ?? {},
|
|
255
282
|
agent,
|
|
256
|
-
// Bearer callers are agents proposing a start; the SPA (cookie) writes what it sent.
|
|
257
|
-
reduce: auth.tokenName !== null,
|
|
258
283
|
});
|
|
259
284
|
if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
|
|
260
285
|
return { status: 201, json: { data: result } };
|
|
@@ -269,7 +294,14 @@ async function dispatch(store, req, url, body, { auth, agent }) {
|
|
|
269
294
|
return { status: 200, json: { data: activity } };
|
|
270
295
|
}
|
|
271
296
|
if (method === 'PUT') {
|
|
272
|
-
|
|
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);
|
|
273
305
|
if (activity?.error === 'not_found' || !activity) return { status: 404, json: { error: 'NOT_FOUND' } };
|
|
274
306
|
return { status: 200, json: { data: activity } };
|
|
275
307
|
}
|
|
@@ -280,6 +312,21 @@ async function dispatch(store, req, url, body, { auth, agent }) {
|
|
|
280
312
|
}
|
|
281
313
|
|
|
282
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
|
+
}
|
|
283
330
|
const result = store.createEvent(userId, {
|
|
284
331
|
traceId: body.trace_id,
|
|
285
332
|
activityId: body.activity_id,
|
|
@@ -309,10 +356,6 @@ async function dispatch(store, req, url, body, { auth, agent }) {
|
|
|
309
356
|
return { status: 200, json: { data: store.userDashboard(userId) } };
|
|
310
357
|
}
|
|
311
358
|
|
|
312
|
-
if (method === 'POST' && path === '/api/todos') {
|
|
313
|
-
return { status: 201, json: { data: store.createTodo(userId, body.todo ?? {}) } };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
359
|
if (method === 'POST' && path === '/api/mantras') {
|
|
317
360
|
return { status: 201, json: { data: store.createMantra(userId, body.mantra ?? {}) } };
|
|
318
361
|
}
|
|
@@ -354,7 +397,8 @@ function isoNow() {
|
|
|
354
397
|
function tryStatic(staticDir, urlPath, res) {
|
|
355
398
|
if (!staticDir) return false;
|
|
356
399
|
const root = resolve(staticDir);
|
|
357
|
-
const
|
|
400
|
+
const rootIndex = join(root, 'index.html');
|
|
401
|
+
const indexPath = existsSync(rootIndex) ? rootIndex : join(root, 'assets', 'index.html');
|
|
358
402
|
const requested = urlPath === '/' || !urlPath.includes('.')
|
|
359
403
|
? indexPath
|
|
360
404
|
: join(root, urlPath.replace(/^\//, ''));
|
|
@@ -376,6 +420,21 @@ function tryStatic(staticDir, urlPath, res) {
|
|
|
376
420
|
export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
|
|
377
421
|
const host = req.headers.host ?? '127.0.0.1';
|
|
378
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
|
+
}
|
|
379
438
|
|
|
380
439
|
if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
|
|
381
440
|
if (tryStatic(staticDir, url.pathname, res)) return;
|
|
@@ -383,8 +442,9 @@ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
|
|
|
383
442
|
const html = `<!doctype html><meta charset="utf-8"><title>Flambe local</title>
|
|
384
443
|
<body style="font-family:sans-serif;max-width:40rem;margin:3rem auto;line-height:1.4">
|
|
385
444
|
<h1>Flambe local</h1>
|
|
386
|
-
<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>
|
|
387
446
|
<pre>FLAMBE_URL=http://${host}
|
|
447
|
+
FLAMBE_RUNTIME_MODE=local_self_contained
|
|
388
448
|
FLAMBE_API_TOKEN=${rawToken}
|
|
389
449
|
FLAMBE_TRACE_ID=${traceId}</pre>
|
|
390
450
|
</body>`;
|
|
@@ -403,7 +463,13 @@ FLAMBE_TRACE_ID=${traceId}</pre>
|
|
|
403
463
|
|
|
404
464
|
const auth = authenticate(store, req);
|
|
405
465
|
const agent = identifyAgent(store, req, auth);
|
|
406
|
-
|
|
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
|
+
}
|
|
407
473
|
const identity = agentHeaders(agent);
|
|
408
474
|
if (result.status === 204) {
|
|
409
475
|
if (result.headers) result.headers.apply(res, 204, identity);
|
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
|
}
|