@columenlabs/lumen-ai 0.1.188 → 0.1.190
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 +21 -0
- package/dist/workspaces/frame.d.ts +30 -0
- package/dist/workspaces/frame.js +126 -0
- package/dist/workspaces/index.d.ts +10 -0
- package/dist/workspaces/index.js +7 -0
- package/dist/workspaces/routes.d.ts +39 -0
- package/dist/workspaces/routes.js +154 -0
- package/dist/workspaces/store.d.ts +118 -0
- package/dist/workspaces/store.js +381 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -251,3 +251,24 @@ class Chat extends LumenAgent<Env> {
|
|
|
251
251
|
protected undoWrite(card: AgentCard) { return assistant.undoChange(caller, card); }
|
|
252
252
|
}
|
|
253
253
|
```
|
|
254
|
+
|
|
255
|
+
## Workspaces: tabs over recipes, run again on demand
|
|
256
|
+
|
|
257
|
+
`@columenlabs/lumen-ai/workspaces` is the server the kit's `WorkspacesSection` and `WorkspacePage` call. A workspace is a person's page of tabs; each tab is a table over a **frame**: the rows one **recipe** pulled, the recipe itself and the person's hand edits. A recipe is one call to one of the product's read tools (`{kind: 'call', fn: 'jobs.list', args}`), so running it again needs no model. Nothing runs on a timer: ↻ on a tab runs its recipe, ↻ in the header runs every tab one after another, each within 15 s (`RUN_MS`); a failed run keeps the last rows and says why.
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { createWorkspaces, doSql, workspaceMigrations, workspaceRoutes } from '@columenlabs/lumen-ai/workspaces';
|
|
261
|
+
|
|
262
|
+
// In the store that holds the person's data (a Durable Object's SQLite, or D1 with d1Sql):
|
|
263
|
+
for (const q of workspaceMigrations({ spaces: 'app_ws_spaces', frames: 'app_ws_frames' })) sql.exec(q);
|
|
264
|
+
const store = createWorkspaces({
|
|
265
|
+
db: doSql(sql),
|
|
266
|
+
tables: { spaces: 'app_ws_spaces', frames: 'app_ws_frames' },
|
|
267
|
+
run: (recipe, { owner, signal }) => callReadTool(owner, recipe, signal), // the product's own tools, as the person
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// In the Worker, at the assistant's api:
|
|
271
|
+
const routes = workspaceRoutes({ base: '/api/assistant', owner: callerOf, store: () => store });
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Budget: one frame is one row (at most 1.5 MB of rows, 10,000 rows, 50 columns), so a run writes one row; every read has a LIMIT (30 workspaces, 12 tabs each, 100 frames). Columns are typed from their values (number, date, text); a list of words, or short text from a small set, becomes a tags column (chips). The kit's `WorkspacesProvider` takes `recipes` to offer + New tab from a recipe in the tab bar.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows as a frame: a tool's answer (a list, or an object with a list field) flattened to typed
|
|
3
|
+
* columns, plus the person's hand edits put back after every run. Pure: no storage, no network.
|
|
4
|
+
*/
|
|
5
|
+
import { type FrameCell, type FrameColumn, type FrameEdit } from '@columenlabs/lumen-ui-types/frames';
|
|
6
|
+
export type Row = Record<string, unknown>;
|
|
7
|
+
/** A frame the person can read back: why it was refused, in words. */
|
|
8
|
+
export declare class FrameError extends Error {
|
|
9
|
+
readonly status: number;
|
|
10
|
+
constructor(message: string, status?: number);
|
|
11
|
+
}
|
|
12
|
+
/** The rows of a value: a list's items, or its first list field, else the value as one row. */
|
|
13
|
+
export declare function asRows(v: unknown): Row[];
|
|
14
|
+
/** Rows as a frame: columns by first sight, each typed from its values. */
|
|
15
|
+
export declare function toFrame(rows: Row[]): {
|
|
16
|
+
columns: FrameColumn[];
|
|
17
|
+
data: FrameCell[][];
|
|
18
|
+
};
|
|
19
|
+
/** A typed value for a column: numbers from numerals (a comma, %, $ or a true minus allowed). */
|
|
20
|
+
export declare function coerce(col: FrameColumn, value: FrameCell): FrameCell;
|
|
21
|
+
/** The latest edit per cell, newest last, at most FRAME_EDITS. */
|
|
22
|
+
export declare function dedupeEdits(edits: FrameEdit[]): FrameEdit[];
|
|
23
|
+
/**
|
|
24
|
+
* Puts the person's hand edits back onto new rows, by row index (a tab's rows come in the order
|
|
25
|
+
* the recipe gives them). An edit whose column or row is gone is dropped.
|
|
26
|
+
*/
|
|
27
|
+
export declare function applyEdits(columns: FrameColumn[], data: FrameCell[][], edits: FrameEdit[]): {
|
|
28
|
+
data: FrameCell[][];
|
|
29
|
+
edits: FrameEdit[];
|
|
30
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows as a frame: a tool's answer (a list, or an object with a list field) flattened to typed
|
|
3
|
+
* columns, plus the person's hand edits put back after every run. Pure: no storage, no network.
|
|
4
|
+
*/
|
|
5
|
+
import { FRAME_COLUMNS, FRAME_EDITS, FRAME_ROWS, columnKey, } from '@columenlabs/lumen-ui-types/frames';
|
|
6
|
+
/** A frame the person can read back: why it was refused, in words. */
|
|
7
|
+
export class FrameError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(message, status = 422) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** The rows of a value: a list's items, or its first list field, else the value as one row. */
|
|
15
|
+
export function asRows(v) {
|
|
16
|
+
const list = Array.isArray(v)
|
|
17
|
+
? v
|
|
18
|
+
: v && typeof v === 'object'
|
|
19
|
+
? (Object.values(v).find(Array.isArray) ?? [v])
|
|
20
|
+
: v === undefined || v === null
|
|
21
|
+
? []
|
|
22
|
+
: [{ value: v }];
|
|
23
|
+
return list.map((r) => r && typeof r === 'object' && !Array.isArray(r) ? r : { value: r });
|
|
24
|
+
}
|
|
25
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}/;
|
|
26
|
+
/** One cell: a list of short words becomes `a, b` (a tags column), anything else nested is JSON. */
|
|
27
|
+
function cell(v) {
|
|
28
|
+
if (v === null || v === undefined)
|
|
29
|
+
return { v: null, list: false };
|
|
30
|
+
if (typeof v === 'number')
|
|
31
|
+
return { v: Number.isFinite(v) ? v : null, list: false };
|
|
32
|
+
if (typeof v === 'string')
|
|
33
|
+
return { v: v.slice(0, 2000), list: false };
|
|
34
|
+
if (typeof v === 'boolean')
|
|
35
|
+
return { v: v ? 'Yes' : 'No', list: false };
|
|
36
|
+
if (Array.isArray(v) && v.every((x) => typeof x === 'string'))
|
|
37
|
+
return { v: v.join(', ').slice(0, 500), list: true };
|
|
38
|
+
return { v: JSON.stringify(v).slice(0, 500), list: false };
|
|
39
|
+
}
|
|
40
|
+
/** Distinct values a text column may hold and still read as tags (stage, source, kind). */
|
|
41
|
+
const TAG_DISTINCT = 12;
|
|
42
|
+
/** Longest value a tag column holds. */
|
|
43
|
+
const TAG_CHARS = 24;
|
|
44
|
+
/** Rows as a frame: columns by first sight, each typed from its values. */
|
|
45
|
+
export function toFrame(rows) {
|
|
46
|
+
if (rows.length > FRAME_ROWS)
|
|
47
|
+
throw new FrameError(`${rows.length.toLocaleString('en-US')} rows: a tab keeps at most ${FRAME_ROWS.toLocaleString('en-US')}. Filter first.`);
|
|
48
|
+
const names = [];
|
|
49
|
+
for (const r of rows)
|
|
50
|
+
for (const k of Object.keys(r))
|
|
51
|
+
if (!names.includes(k))
|
|
52
|
+
names.push(k);
|
|
53
|
+
if (names.length > FRAME_COLUMNS)
|
|
54
|
+
throw new FrameError(`${names.length} columns: a tab keeps at most ${FRAME_COLUMNS}. Ask for fewer fields.`);
|
|
55
|
+
const lists = new Set();
|
|
56
|
+
const data = rows.map((r) => names.map((n, i) => {
|
|
57
|
+
const c = cell(r[n]);
|
|
58
|
+
if (c.list)
|
|
59
|
+
lists.add(i);
|
|
60
|
+
return c.v;
|
|
61
|
+
}));
|
|
62
|
+
const taken = new Set();
|
|
63
|
+
const columns = names.map((label, i) => {
|
|
64
|
+
const values = data.map((d) => d[i]).filter((v) => v !== null);
|
|
65
|
+
const type = values.length && values.every((v) => typeof v === 'number')
|
|
66
|
+
? 'number'
|
|
67
|
+
: values.length && values.every((v) => typeof v === 'string' && ISO_DAY.test(v))
|
|
68
|
+
? 'date'
|
|
69
|
+
: 'text';
|
|
70
|
+
// Short values from a small set (or lists of words) read as coloured chips, not text.
|
|
71
|
+
const distinct = new Set(values);
|
|
72
|
+
const tags = type === 'text' &&
|
|
73
|
+
(lists.has(i) ||
|
|
74
|
+
(values.length >= 4 &&
|
|
75
|
+
distinct.size <= TAG_DISTINCT &&
|
|
76
|
+
distinct.size < values.length &&
|
|
77
|
+
values.every((v) => String(v).length <= TAG_CHARS)));
|
|
78
|
+
return {
|
|
79
|
+
key: columnKey(label, taken),
|
|
80
|
+
label: label.slice(0, 40),
|
|
81
|
+
type,
|
|
82
|
+
format: type === 'number' ? 'number' : 'text',
|
|
83
|
+
...(tags ? { tags: true } : {}),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
return { columns, data };
|
|
87
|
+
}
|
|
88
|
+
/** A typed value for a column: numbers from numerals (a comma, %, $ or a true minus allowed). */
|
|
89
|
+
export function coerce(col, value) {
|
|
90
|
+
if (value === null || value === '')
|
|
91
|
+
return null;
|
|
92
|
+
if (col.type !== 'number')
|
|
93
|
+
return typeof value === 'number' ? String(value) : value.slice(0, 2000);
|
|
94
|
+
if (typeof value === 'number')
|
|
95
|
+
return Number.isFinite(value) ? value : null;
|
|
96
|
+
const n = Number(value.replace(/[,$%\s]/g, '').replace('−', '-'));
|
|
97
|
+
if (!Number.isFinite(n))
|
|
98
|
+
throw new FrameError(`${col.label} holds numbers: "${value}" is not one.`);
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
/** The latest edit per cell, newest last, at most FRAME_EDITS. */
|
|
102
|
+
export function dedupeEdits(edits) {
|
|
103
|
+
const seen = new Map();
|
|
104
|
+
for (const e of edits)
|
|
105
|
+
seen.set(`${e.column}\u0000${e.key ?? `#${e.row}`}`, e);
|
|
106
|
+
return [...seen.values()].slice(-FRAME_EDITS);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Puts the person's hand edits back onto new rows, by row index (a tab's rows come in the order
|
|
110
|
+
* the recipe gives them). An edit whose column or row is gone is dropped.
|
|
111
|
+
*/
|
|
112
|
+
export function applyEdits(columns, data, edits) {
|
|
113
|
+
if (!edits.length)
|
|
114
|
+
return { data, edits };
|
|
115
|
+
const rows = data.map((r) => [...r]);
|
|
116
|
+
const kept = [];
|
|
117
|
+
for (const e of edits) {
|
|
118
|
+
const col = columns.findIndex((c) => c.key === e.column);
|
|
119
|
+
const row = e.row === undefined ? undefined : rows[e.row];
|
|
120
|
+
if (col < 0 || !row)
|
|
121
|
+
continue;
|
|
122
|
+
row[col] = e.value;
|
|
123
|
+
kept.push(e);
|
|
124
|
+
}
|
|
125
|
+
return { data: rows, edits: kept };
|
|
126
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspaces: a person's pages of tabs, each a table over rows a recipe pulled, run again on demand.
|
|
3
|
+
* `createWorkspaces` is the store (any SQLite), `workspaceRoutes` the HTTP the kit's pages call.
|
|
4
|
+
*/
|
|
5
|
+
export { FrameError, applyEdits, asRows, coerce, dedupeEdits, toFrame } from './frame.js';
|
|
6
|
+
export type { Row } from './frame.js';
|
|
7
|
+
export { FRAME_BYTES, MAX_FRAMES, MAX_TABS, MAX_WORKSPACES, RUN_MS, createWorkspaces, d1Sql, doSql, recipeText, workspaceMigrations, } from './store.js';
|
|
8
|
+
export type { RecipeRunner, SqlRunner, WorkspaceRead, WorkspaceTables, Workspaces, WorkspacesOptions, } from './store.js';
|
|
9
|
+
export { workspaceRoutes } from './routes.js';
|
|
10
|
+
export type { WorkspaceRoutesOptions } from './routes.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspaces: a person's pages of tabs, each a table over rows a recipe pulled, run again on demand.
|
|
3
|
+
* `createWorkspaces` is the store (any SQLite), `workspaceRoutes` the HTTP the kit's pages call.
|
|
4
|
+
*/
|
|
5
|
+
export { FrameError, applyEdits, asRows, coerce, dedupeEdits, toFrame } from './frame.js';
|
|
6
|
+
export { FRAME_BYTES, MAX_FRAMES, MAX_TABS, MAX_WORKSPACES, RUN_MS, createWorkspaces, d1Sql, doSql, recipeText, workspaceMigrations, } from './store.js';
|
|
7
|
+
export { workspaceRoutes } from './routes.js';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP routes the kit's workspace pages call (`WorkspacesSection`, `WorkspacePage` in
|
|
3
|
+
* `@columenlabs/lumen-ui`), as one fetch handler on Web `Request`/`Response`, mounted at the
|
|
4
|
+
* assistant's api (`/api/assistant`):
|
|
5
|
+
*
|
|
6
|
+
* GET /workspaces the caller's workspaces
|
|
7
|
+
* POST /workspaces {name, app?} a new one
|
|
8
|
+
* GET /workspaces/:id one, with its frames' heads
|
|
9
|
+
* PATCH /workspaces/:id {name?, app?}
|
|
10
|
+
* DELETE /workspaces/:id
|
|
11
|
+
* POST /workspaces/:id/tabs {name, recipe} a new tab from a recipe (runs it once)
|
|
12
|
+
* POST /workspaces/:id/refresh runs every tab's recipe again, one after another
|
|
13
|
+
* PUT /workspaces/:id/tabs/:tab {name?, config?}
|
|
14
|
+
* DELETE /workspaces/:id/tabs/:tab
|
|
15
|
+
* GET /frames every frame (for + Data)
|
|
16
|
+
* GET /frames/:id rows, meta and hand edits
|
|
17
|
+
* POST /frames/:id/refresh runs its recipe again
|
|
18
|
+
* GET|PUT /frames/:id/script its recipe
|
|
19
|
+
* PATCH /frames/:id/cells {edits} hand edits
|
|
20
|
+
*
|
|
21
|
+
* Every call needs a caller; writes must be same-origin JSON, since a signed-in browser session is
|
|
22
|
+
* a cookie and a foreign page must not reach a person's data.
|
|
23
|
+
*/
|
|
24
|
+
import { frameScriptSchema } from '@columenlabs/lumen-ui-types/frames';
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import type { Workspaces } from './store.js';
|
|
27
|
+
type Awaitable<T> = T | Promise<T>;
|
|
28
|
+
export interface WorkspaceRoutesOptions {
|
|
29
|
+
/** Where the routes are mounted (`/api/assistant`). */
|
|
30
|
+
base: string;
|
|
31
|
+
/** The store for this request (the caller's own). */
|
|
32
|
+
store: (req: Request) => Awaitable<Workspaces>;
|
|
33
|
+
/** The verified caller's owner id, or null (403). */
|
|
34
|
+
owner: (req: Request) => Awaitable<string | null>;
|
|
35
|
+
/** Which recipes this product runs; a recipe it refuses answers 422 with the reason. */
|
|
36
|
+
allow?: (recipe: z.infer<typeof frameScriptSchema>, req: Request) => Awaitable<string | null>;
|
|
37
|
+
}
|
|
38
|
+
export declare function workspaceRoutes(opts: WorkspaceRoutesOptions): (req: Request) => Promise<Response>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP routes the kit's workspace pages call (`WorkspacesSection`, `WorkspacePage` in
|
|
3
|
+
* `@columenlabs/lumen-ui`), as one fetch handler on Web `Request`/`Response`, mounted at the
|
|
4
|
+
* assistant's api (`/api/assistant`):
|
|
5
|
+
*
|
|
6
|
+
* GET /workspaces the caller's workspaces
|
|
7
|
+
* POST /workspaces {name, app?} a new one
|
|
8
|
+
* GET /workspaces/:id one, with its frames' heads
|
|
9
|
+
* PATCH /workspaces/:id {name?, app?}
|
|
10
|
+
* DELETE /workspaces/:id
|
|
11
|
+
* POST /workspaces/:id/tabs {name, recipe} a new tab from a recipe (runs it once)
|
|
12
|
+
* POST /workspaces/:id/refresh runs every tab's recipe again, one after another
|
|
13
|
+
* PUT /workspaces/:id/tabs/:tab {name?, config?}
|
|
14
|
+
* DELETE /workspaces/:id/tabs/:tab
|
|
15
|
+
* GET /frames every frame (for + Data)
|
|
16
|
+
* GET /frames/:id rows, meta and hand edits
|
|
17
|
+
* POST /frames/:id/refresh runs its recipe again
|
|
18
|
+
* GET|PUT /frames/:id/script its recipe
|
|
19
|
+
* PATCH /frames/:id/cells {edits} hand edits
|
|
20
|
+
*
|
|
21
|
+
* Every call needs a caller; writes must be same-origin JSON, since a signed-in browser session is
|
|
22
|
+
* a cookie and a foreign page must not reach a person's data.
|
|
23
|
+
*/
|
|
24
|
+
import { frameEditSchema, frameName, frameScriptSchema } from '@columenlabs/lumen-ui-types/frames';
|
|
25
|
+
import { customViewConfigSchema } from '@columenlabs/lumen-ui-types/views';
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import { FrameError } from './frame.js';
|
|
28
|
+
const HEADERS = { 'content-type': 'application/json', 'cache-control': 'no-store' };
|
|
29
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: HEADERS });
|
|
30
|
+
const fail = (error, status) => json({ error }, status);
|
|
31
|
+
const createSchema = z.object({ name: frameName, app: z.string().max(40).optional() });
|
|
32
|
+
const patchSchema = z.object({ name: frameName.optional(), app: z.string().max(40).optional() });
|
|
33
|
+
const addTabSchema = z.object({ name: frameName, recipe: frameScriptSchema });
|
|
34
|
+
const putTabSchema = z.object({
|
|
35
|
+
name: frameName.optional(),
|
|
36
|
+
config: customViewConfigSchema.nullable().optional(),
|
|
37
|
+
});
|
|
38
|
+
const cellsSchema = z.object({ edits: z.array(frameEditSchema).min(1).max(200) });
|
|
39
|
+
const scriptSchema = z.object({ script: frameScriptSchema });
|
|
40
|
+
export function workspaceRoutes(opts) {
|
|
41
|
+
const base = opts.base.replace(/\/$/, '');
|
|
42
|
+
return async (req) => {
|
|
43
|
+
const url = new URL(req.url);
|
|
44
|
+
if (!url.pathname.startsWith(base))
|
|
45
|
+
return fail('not_found', 404);
|
|
46
|
+
const parts = url.pathname
|
|
47
|
+
.slice(base.length)
|
|
48
|
+
.split('/')
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.map(decodeURIComponent);
|
|
51
|
+
const owner = await opts.owner(req);
|
|
52
|
+
if (!owner)
|
|
53
|
+
return fail('forbidden', 403);
|
|
54
|
+
const write = req.method !== 'GET';
|
|
55
|
+
if (write) {
|
|
56
|
+
if (req.headers.get('origin') !== url.origin)
|
|
57
|
+
return fail('forbidden_origin', 403);
|
|
58
|
+
if (req.method !== 'DELETE' &&
|
|
59
|
+
!(req.headers.get('content-type') ?? '').toLowerCase().startsWith('application/json'))
|
|
60
|
+
return fail('json_only', 415);
|
|
61
|
+
}
|
|
62
|
+
const body = async (schema) => {
|
|
63
|
+
const parsed = schema.safeParse(await req.json().catch(() => null));
|
|
64
|
+
if (!parsed.success)
|
|
65
|
+
throw new FrameError(parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '), 400);
|
|
66
|
+
return parsed.data;
|
|
67
|
+
};
|
|
68
|
+
const allowed = async (recipe) => {
|
|
69
|
+
if (recipe.kind !== 'call')
|
|
70
|
+
throw new FrameError('Recipes here are one call to an app tool.', 422);
|
|
71
|
+
const why = opts.allow ? await opts.allow(recipe, req) : null;
|
|
72
|
+
if (why)
|
|
73
|
+
throw new FrameError(why, 422);
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
const s = await opts.store(req);
|
|
77
|
+
const [root, id, sub, tab] = parts;
|
|
78
|
+
const m = req.method;
|
|
79
|
+
if (root === 'workspaces') {
|
|
80
|
+
if (!id) {
|
|
81
|
+
if (m === 'GET')
|
|
82
|
+
return json(await s.list(owner));
|
|
83
|
+
if (m === 'POST')
|
|
84
|
+
return json(await s.create(owner, await body(createSchema)));
|
|
85
|
+
}
|
|
86
|
+
else if (!sub) {
|
|
87
|
+
if (m === 'GET')
|
|
88
|
+
return json(await s.read(owner, id));
|
|
89
|
+
if (m === 'PATCH') {
|
|
90
|
+
const p = await body(patchSchema);
|
|
91
|
+
return json(await s.patch(owner, id, p));
|
|
92
|
+
}
|
|
93
|
+
if (m === 'DELETE') {
|
|
94
|
+
await s.remove(owner, id);
|
|
95
|
+
return json({ ok: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (sub === 'refresh' && !tab && m === 'POST') {
|
|
99
|
+
return json(await s.refreshAll(owner, id));
|
|
100
|
+
}
|
|
101
|
+
else if (sub === 'tabs') {
|
|
102
|
+
if (!tab && m === 'POST') {
|
|
103
|
+
const p = await body(addTabSchema);
|
|
104
|
+
await allowed(p.recipe);
|
|
105
|
+
return json(await s.addTab(owner, id, { name: p.name, recipe: p.recipe }));
|
|
106
|
+
}
|
|
107
|
+
if (tab && m === 'PUT') {
|
|
108
|
+
const p = await body(putTabSchema);
|
|
109
|
+
return json(await s.putTab(owner, id, tab, {
|
|
110
|
+
name: p.name,
|
|
111
|
+
// The schema checks the layout; the tab's name rides beside it.
|
|
112
|
+
config: p.config,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
if (tab && m === 'DELETE')
|
|
116
|
+
return json(await s.removeTab(owner, id, tab));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (root === 'frames') {
|
|
120
|
+
if (!id) {
|
|
121
|
+
if (m === 'GET')
|
|
122
|
+
return json(await s.frames(owner));
|
|
123
|
+
if (m === 'POST')
|
|
124
|
+
return fail('Your own columns are not offered here yet.', 501);
|
|
125
|
+
}
|
|
126
|
+
else if (!sub && m === 'GET') {
|
|
127
|
+
return json(await s.frame(owner, id));
|
|
128
|
+
}
|
|
129
|
+
else if (sub === 'refresh' && m === 'POST') {
|
|
130
|
+
return json(await s.refresh(owner, id));
|
|
131
|
+
}
|
|
132
|
+
else if (sub === 'script') {
|
|
133
|
+
if (m === 'GET')
|
|
134
|
+
return json(await s.script(owner, id));
|
|
135
|
+
if (m === 'PUT') {
|
|
136
|
+
const p = await body(scriptSchema);
|
|
137
|
+
await allowed(p.script);
|
|
138
|
+
return json(await s.setScript(owner, id, p.script));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (sub === 'cells' && m === 'PATCH') {
|
|
142
|
+
return json(await s.editCells(owner, id, (await body(cellsSchema)).edits));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return fail('not_found', 404);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err instanceof FrameError)
|
|
149
|
+
return fail(err.message, err.status);
|
|
150
|
+
console.error(JSON.stringify({ event: 'workspaces_error', error: String(err) }));
|
|
151
|
+
return fail('Something went wrong.', 500);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspaces on the server: a person's pages of tabs, each tab a table over a frame (the rows one
|
|
3
|
+
* recipe pulled, the recipe that pulled them, and the person's hand edits). Any SQLite holds them:
|
|
4
|
+
* a Durable Object's own (`doSql`) or D1 (`d1Sql`). The product runs recipes (`run`): a recipe is a
|
|
5
|
+
* call to one of its read tools as the person, so running it again needs no model.
|
|
6
|
+
*
|
|
7
|
+
* Budget: a list reads at most `MAX_WORKSPACES` rows, a workspace one row plus its frames' heads,
|
|
8
|
+
* a frame one row. A run writes one row (the frame); nothing here runs on a timer.
|
|
9
|
+
*
|
|
10
|
+
* Tables (names from `tables`, so a store with a prefix rule keeps it):
|
|
11
|
+
* spaces: owner, id, name, app, tabs (JSON), created_at, updated_at
|
|
12
|
+
* frames: owner, id, name, head (JSON: meta + recipe), rows (JSON), edits (JSON), at
|
|
13
|
+
*/
|
|
14
|
+
import { type FrameChoice, type FrameData, type FrameEditInput, type FrameMeta, type FrameScript } from '@columenlabs/lumen-ui-types/frames';
|
|
15
|
+
import type { CustomViewConfig } from '@columenlabs/lumen-ui-types/views';
|
|
16
|
+
import type { Workspace, WorkspaceSummary } from '@columenlabs/lumen-ui-types/workspaces';
|
|
17
|
+
type Row = Record<string, unknown>;
|
|
18
|
+
/** The SQL a store needs: the same shape as `lumen-tasks`' `SqlRunner`, so one runner serves both. */
|
|
19
|
+
export interface SqlRunner {
|
|
20
|
+
all(sql: string, ...args: unknown[]): Promise<Row[]>;
|
|
21
|
+
run(sql: string, ...args: unknown[]): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** A Durable Object's SQLite (`ctx.storage.sql`). */
|
|
24
|
+
export declare function doSql(sql: {
|
|
25
|
+
exec(q: string, ...args: unknown[]): {
|
|
26
|
+
toArray(): unknown[];
|
|
27
|
+
};
|
|
28
|
+
}): SqlRunner;
|
|
29
|
+
/** A D1 database binding. */
|
|
30
|
+
export declare function d1Sql(db: {
|
|
31
|
+
prepare(q: string): {
|
|
32
|
+
bind(...a: unknown[]): {
|
|
33
|
+
all(): Promise<{
|
|
34
|
+
results: unknown[];
|
|
35
|
+
}>;
|
|
36
|
+
run(): Promise<unknown>;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}): SqlRunner;
|
|
40
|
+
export declare const MAX_WORKSPACES = 30;
|
|
41
|
+
export declare const MAX_TABS = 12;
|
|
42
|
+
export declare const MAX_FRAMES = 100;
|
|
43
|
+
/** Most bytes a frame's rows keep as JSON (a SQLite row holds 2 MB). */
|
|
44
|
+
export declare const FRAME_BYTES = 1500000;
|
|
45
|
+
/** How long one recipe run may take before it gives up (the tab keeps its last rows). */
|
|
46
|
+
export declare const RUN_MS = 15000;
|
|
47
|
+
export interface WorkspaceTables {
|
|
48
|
+
spaces: string;
|
|
49
|
+
frames: string;
|
|
50
|
+
}
|
|
51
|
+
/** The tables' SQL, for a store that runs its own migrations (a person store module). */
|
|
52
|
+
export declare function workspaceMigrations(t?: WorkspaceTables): string[];
|
|
53
|
+
/** What runs a recipe: the product calls its read tool as the person and gives back its answer. */
|
|
54
|
+
export type RecipeRunner = (recipe: FrameScript, ctx: {
|
|
55
|
+
owner: string;
|
|
56
|
+
signal: AbortSignal;
|
|
57
|
+
}) => Promise<unknown>;
|
|
58
|
+
export interface WorkspacesOptions {
|
|
59
|
+
db: SqlRunner;
|
|
60
|
+
run: RecipeRunner;
|
|
61
|
+
/** The app new workspaces belong to (one-app products leave it). */
|
|
62
|
+
app?: string;
|
|
63
|
+
tables?: WorkspaceTables;
|
|
64
|
+
now?: () => number;
|
|
65
|
+
}
|
|
66
|
+
export type WorkspaceRead = Workspace & {
|
|
67
|
+
frames: Record<string, FrameMeta>;
|
|
68
|
+
};
|
|
69
|
+
/** The recipe's text as the person reads it: `jobs.list` and its arguments. */
|
|
70
|
+
export declare function recipeText(s: FrameScript | null): string;
|
|
71
|
+
export declare function createWorkspaces(opts: WorkspacesOptions): {
|
|
72
|
+
list(owner: string): Promise<{
|
|
73
|
+
workspaces: WorkspaceSummary[];
|
|
74
|
+
}>;
|
|
75
|
+
create(owner: string, p: {
|
|
76
|
+
name: string;
|
|
77
|
+
app?: string;
|
|
78
|
+
}): Promise<Workspace>;
|
|
79
|
+
read(owner: string, id: string): Promise<WorkspaceRead>;
|
|
80
|
+
patch(owner: string, id: string, p: {
|
|
81
|
+
name?: string;
|
|
82
|
+
app?: string;
|
|
83
|
+
}): Promise<Workspace>;
|
|
84
|
+
remove(owner: string, id: string): Promise<void>;
|
|
85
|
+
/** A new tab from a recipe: runs it once, keeps the rows as a frame, adds the tab. */
|
|
86
|
+
addTab(owner: string, id: string, p: {
|
|
87
|
+
name: string;
|
|
88
|
+
recipe: FrameScript;
|
|
89
|
+
by?: 'you' | 'assistant';
|
|
90
|
+
}): Promise<{
|
|
91
|
+
workspace: WorkspaceRead;
|
|
92
|
+
tab: string;
|
|
93
|
+
}>;
|
|
94
|
+
putTab(owner: string, id: string, tab: string, p: {
|
|
95
|
+
name?: string;
|
|
96
|
+
config?: CustomViewConfig | null;
|
|
97
|
+
}): Promise<Workspace>;
|
|
98
|
+
removeTab(owner: string, id: string, tab: string): Promise<Workspace>;
|
|
99
|
+
/** Runs every tab's recipe again, one after another; a failed one leaves its last rows. */
|
|
100
|
+
refreshAll(owner: string, id: string): Promise<{
|
|
101
|
+
ran: number;
|
|
102
|
+
failed: string[];
|
|
103
|
+
rows: number;
|
|
104
|
+
}>;
|
|
105
|
+
frames(owner: string): Promise<{
|
|
106
|
+
frames: FrameChoice[];
|
|
107
|
+
}>;
|
|
108
|
+
frame(owner: string, id: string): Promise<FrameData>;
|
|
109
|
+
refresh: (owner: string, id: string, by?: 'you' | 'assistant') => Promise<FrameData>;
|
|
110
|
+
script(owner: string, id: string): Promise<{
|
|
111
|
+
script: FrameScript | null;
|
|
112
|
+
}>;
|
|
113
|
+
/** A new recipe for a frame: kept only if it runs. */
|
|
114
|
+
setScript(owner: string, id: string, script: FrameScript): Promise<FrameData>;
|
|
115
|
+
editCells(owner: string, id: string, edits: FrameEditInput[]): Promise<FrameData>;
|
|
116
|
+
};
|
|
117
|
+
export type Workspaces = ReturnType<typeof createWorkspaces>;
|
|
118
|
+
export {};
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspaces on the server: a person's pages of tabs, each tab a table over a frame (the rows one
|
|
3
|
+
* recipe pulled, the recipe that pulled them, and the person's hand edits). Any SQLite holds them:
|
|
4
|
+
* a Durable Object's own (`doSql`) or D1 (`d1Sql`). The product runs recipes (`run`): a recipe is a
|
|
5
|
+
* call to one of its read tools as the person, so running it again needs no model.
|
|
6
|
+
*
|
|
7
|
+
* Budget: a list reads at most `MAX_WORKSPACES` rows, a workspace one row plus its frames' heads,
|
|
8
|
+
* a frame one row. A run writes one row (the frame); nothing here runs on a timer.
|
|
9
|
+
*
|
|
10
|
+
* Tables (names from `tables`, so a store with a prefix rule keeps it):
|
|
11
|
+
* spaces: owner, id, name, app, tabs (JSON), created_at, updated_at
|
|
12
|
+
* frames: owner, id, name, head (JSON: meta + recipe), rows (JSON), edits (JSON), at
|
|
13
|
+
*/
|
|
14
|
+
import { FRAME_RUNS, } from '@columenlabs/lumen-ui-types/frames';
|
|
15
|
+
import { FrameError, applyEdits, asRows, coerce, dedupeEdits, toFrame } from './frame.js';
|
|
16
|
+
/** A Durable Object's SQLite (`ctx.storage.sql`). */
|
|
17
|
+
export function doSql(sql) {
|
|
18
|
+
return {
|
|
19
|
+
all: async (q, ...a) => sql.exec(q, ...a).toArray(),
|
|
20
|
+
run: async (q, ...a) => {
|
|
21
|
+
sql.exec(q, ...a);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** A D1 database binding. */
|
|
26
|
+
export function d1Sql(db) {
|
|
27
|
+
return {
|
|
28
|
+
all: async (q, ...a) => (await db
|
|
29
|
+
.prepare(q)
|
|
30
|
+
.bind(...a)
|
|
31
|
+
.all()).results,
|
|
32
|
+
run: async (q, ...a) => {
|
|
33
|
+
await db
|
|
34
|
+
.prepare(q)
|
|
35
|
+
.bind(...a)
|
|
36
|
+
.run();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export const MAX_WORKSPACES = 30;
|
|
41
|
+
export const MAX_TABS = 12;
|
|
42
|
+
export const MAX_FRAMES = 100;
|
|
43
|
+
/** Most bytes a frame's rows keep as JSON (a SQLite row holds 2 MB). */
|
|
44
|
+
export const FRAME_BYTES = 1_500_000;
|
|
45
|
+
/** How long one recipe run may take before it gives up (the tab keeps its last rows). */
|
|
46
|
+
export const RUN_MS = 15_000;
|
|
47
|
+
const TABLES = { spaces: 'ws_spaces', frames: 'ws_frames' };
|
|
48
|
+
/** The tables' SQL, for a store that runs its own migrations (a person store module). */
|
|
49
|
+
export function workspaceMigrations(t = TABLES) {
|
|
50
|
+
return [
|
|
51
|
+
`CREATE TABLE IF NOT EXISTS ${t.spaces} (owner TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, app TEXT NOT NULL, tabs TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (owner, id))`,
|
|
52
|
+
`CREATE TABLE IF NOT EXISTS ${t.frames} (owner TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, head TEXT NOT NULL, rows TEXT NOT NULL, edits TEXT NOT NULL, at TEXT NOT NULL, PRIMARY KEY (owner, id))`,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
const newId = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`;
|
|
56
|
+
const parse = (v, d) => {
|
|
57
|
+
try {
|
|
58
|
+
return typeof v === 'string' ? JSON.parse(v) : d;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return d;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const metaOf = ({ script: _s, ...m }) => m;
|
|
65
|
+
/** The recipe's text as the person reads it: `jobs.list` and its arguments. */
|
|
66
|
+
export function recipeText(s) {
|
|
67
|
+
if (!s)
|
|
68
|
+
return 'nothing (rows written once)';
|
|
69
|
+
if (s.kind === 'call')
|
|
70
|
+
return `${s.fn} ${JSON.stringify(s.args)}`;
|
|
71
|
+
return s.kind === 'sql' ? 'a SQL query' : 'a script';
|
|
72
|
+
}
|
|
73
|
+
export function createWorkspaces(opts) {
|
|
74
|
+
const t = opts.tables ?? TABLES;
|
|
75
|
+
const now = () => new Date((opts.now ?? Date.now)()).toISOString();
|
|
76
|
+
const db = opts.db;
|
|
77
|
+
const spaceOf = (r) => ({
|
|
78
|
+
id: String(r.id),
|
|
79
|
+
name: String(r.name),
|
|
80
|
+
app: String(r.app),
|
|
81
|
+
tabs: parse(r.tabs, []),
|
|
82
|
+
created_at: String(r.created_at),
|
|
83
|
+
updated_at: String(r.updated_at),
|
|
84
|
+
});
|
|
85
|
+
async function getSpace(owner, id) {
|
|
86
|
+
const [r] = await db.all(`SELECT id, name, app, tabs, created_at, updated_at FROM ${t.spaces} WHERE owner = ? AND id = ? LIMIT 1`, owner, id);
|
|
87
|
+
if (!r)
|
|
88
|
+
throw new FrameError('No workspace with this id.', 404);
|
|
89
|
+
return spaceOf(r);
|
|
90
|
+
}
|
|
91
|
+
async function saveSpace(owner, w) {
|
|
92
|
+
const next = { ...w, updated_at: now() };
|
|
93
|
+
await db.run(`UPDATE ${t.spaces} SET name = ?, app = ?, tabs = ?, updated_at = ? WHERE owner = ? AND id = ?`, next.name, next.app, JSON.stringify(next.tabs), next.updated_at, owner, w.id);
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
96
|
+
async function heads(owner, ids) {
|
|
97
|
+
const out = new Map();
|
|
98
|
+
if (!ids.length)
|
|
99
|
+
return out;
|
|
100
|
+
const rows = await db.all(`SELECT id, head FROM ${t.frames} WHERE owner = ? AND id IN (${ids.map(() => '?').join(', ')}) LIMIT ${ids.length}`, owner, ...ids);
|
|
101
|
+
for (const r of rows)
|
|
102
|
+
out.set(String(r.id), parse(r.head, null));
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
async function getFrame(owner, id) {
|
|
106
|
+
const [r] = await db.all(`SELECT head, rows, edits FROM ${t.frames} WHERE owner = ? AND id = ? LIMIT 1`, owner, id);
|
|
107
|
+
if (!r)
|
|
108
|
+
throw new FrameError('No frame with this id.', 404);
|
|
109
|
+
const head = parse(r.head, null);
|
|
110
|
+
return {
|
|
111
|
+
head,
|
|
112
|
+
data: {
|
|
113
|
+
meta: metaOf(head),
|
|
114
|
+
rows: parse(r.rows, []),
|
|
115
|
+
edits: parse(r.edits, []),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function writeFrame(owner, head, rows, edits) {
|
|
120
|
+
const body = JSON.stringify(rows);
|
|
121
|
+
if (body.length > FRAME_BYTES)
|
|
122
|
+
throw new FrameError('These rows are too big for one tab: ask the recipe for fewer rows or fields.');
|
|
123
|
+
await db.run(`INSERT INTO ${t.frames} (owner, id, name, head, rows, edits, at) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
124
|
+
ON CONFLICT (owner, id) DO UPDATE SET name = excluded.name, head = excluded.head, rows = excluded.rows, edits = excluded.edits, at = excluded.at`, owner, head.id, head.name, JSON.stringify(head), body, JSON.stringify(edits), head.at);
|
|
125
|
+
}
|
|
126
|
+
/** Runs a recipe with a time limit, as the person. */
|
|
127
|
+
async function runRecipe(owner, recipe) {
|
|
128
|
+
const signal = AbortSignal.timeout(RUN_MS);
|
|
129
|
+
let timer;
|
|
130
|
+
try {
|
|
131
|
+
return await Promise.race([
|
|
132
|
+
opts.run(recipe, { owner, signal }),
|
|
133
|
+
new Promise((_, no) => {
|
|
134
|
+
timer = setTimeout(() => no(new FrameError(`The recipe took over ${RUN_MS / 1000} s, so it stopped.`, 504)), RUN_MS);
|
|
135
|
+
}),
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Runs a frame's recipe and keeps the rows (hand edits put back). A failed run keeps the last
|
|
144
|
+
* good rows and says why on the frame; it throws so the caller can tell the person.
|
|
145
|
+
*/
|
|
146
|
+
async function refresh(owner, id, by = 'you') {
|
|
147
|
+
const { head, data } = await getFrame(owner, id);
|
|
148
|
+
if (!head.script)
|
|
149
|
+
throw new FrameError('This tab was written once: it has no recipe to run.', 409);
|
|
150
|
+
return runInto(owner, head, data.edits ?? [], head.script, by, data.rows);
|
|
151
|
+
}
|
|
152
|
+
async function runInto(owner, head, edits, script, by, lastRows) {
|
|
153
|
+
const started = Date.now();
|
|
154
|
+
const at = now();
|
|
155
|
+
try {
|
|
156
|
+
const value = await runRecipe(owner, script);
|
|
157
|
+
const { columns, data } = toFrame(asRows(value));
|
|
158
|
+
const put = applyEdits(columns, data, edits);
|
|
159
|
+
const next = {
|
|
160
|
+
...head,
|
|
161
|
+
columns,
|
|
162
|
+
rows: put.data.length,
|
|
163
|
+
at,
|
|
164
|
+
script,
|
|
165
|
+
error: undefined,
|
|
166
|
+
edited: put.edits.length || undefined,
|
|
167
|
+
runs: [
|
|
168
|
+
{ at, rows: put.data.length, ms: Date.now() - started, ok: true, by },
|
|
169
|
+
...(head.runs ?? []),
|
|
170
|
+
].slice(0, FRAME_RUNS),
|
|
171
|
+
};
|
|
172
|
+
await writeFrame(owner, next, put.data, put.edits);
|
|
173
|
+
return { meta: metaOf(next), rows: put.data, edits: put.edits };
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
// The product's runner speaks to the person (a tool that is not a read, a grant missing).
|
|
177
|
+
const error = (err instanceof Error && err.message ? err.message : 'The recipe failed.').slice(0, 300);
|
|
178
|
+
const failed = {
|
|
179
|
+
...head,
|
|
180
|
+
error,
|
|
181
|
+
runs: [
|
|
182
|
+
{ at, rows: 0, ms: Date.now() - started, ok: false, by, error },
|
|
183
|
+
...(head.runs ?? []),
|
|
184
|
+
].slice(0, FRAME_RUNS),
|
|
185
|
+
};
|
|
186
|
+
// A brand-new frame with no rows is not kept: the caller says why instead.
|
|
187
|
+
if (lastRows.length || head.runs?.length)
|
|
188
|
+
await writeFrame(owner, failed, lastRows, edits);
|
|
189
|
+
throw err instanceof FrameError ? err : new FrameError(error, 502);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function dropFrameIfUnused(owner, frame) {
|
|
193
|
+
const rows = await db.all(`SELECT tabs FROM ${t.spaces} WHERE owner = ? LIMIT ${MAX_WORKSPACES}`, owner);
|
|
194
|
+
const used = rows.some((r) => parse(r.tabs, []).some((x) => 'frame' in x.source && x.source.frame === frame));
|
|
195
|
+
if (!used)
|
|
196
|
+
await db.run(`DELETE FROM ${t.frames} WHERE owner = ? AND id = ?`, owner, frame);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
async list(owner) {
|
|
200
|
+
const rows = await db.all(`SELECT id, name, app, tabs, created_at, updated_at FROM ${t.spaces} WHERE owner = ? ORDER BY updated_at DESC LIMIT ${MAX_WORKSPACES}`, owner);
|
|
201
|
+
return {
|
|
202
|
+
workspaces: rows.map((r) => {
|
|
203
|
+
const w = spaceOf(r);
|
|
204
|
+
return {
|
|
205
|
+
id: w.id,
|
|
206
|
+
name: w.name,
|
|
207
|
+
app: w.app,
|
|
208
|
+
updated_at: w.updated_at,
|
|
209
|
+
tabs: w.tabs.map((x) => ({
|
|
210
|
+
id: x.id,
|
|
211
|
+
name: x.name,
|
|
212
|
+
kind: 'frame' in x.source ? 'frame' : 'domain',
|
|
213
|
+
})),
|
|
214
|
+
};
|
|
215
|
+
}),
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
async create(owner, p) {
|
|
219
|
+
const [{ n } = { n: 0 }] = await db.all(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM ${t.spaces} WHERE owner = ? LIMIT ${MAX_WORKSPACES})`, owner);
|
|
220
|
+
if (Number(n) >= MAX_WORKSPACES)
|
|
221
|
+
throw new FrameError(`You have ${MAX_WORKSPACES} workspaces, the most: delete one first.`, 409);
|
|
222
|
+
const at = now();
|
|
223
|
+
const w = {
|
|
224
|
+
id: newId('ws'),
|
|
225
|
+
name: p.name,
|
|
226
|
+
app: p.app ?? opts.app ?? 'app',
|
|
227
|
+
tabs: [],
|
|
228
|
+
created_at: at,
|
|
229
|
+
updated_at: at,
|
|
230
|
+
};
|
|
231
|
+
await db.run(`INSERT INTO ${t.spaces} (owner, id, name, app, tabs, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, owner, w.id, w.name, w.app, '[]', at, at);
|
|
232
|
+
return w;
|
|
233
|
+
},
|
|
234
|
+
async read(owner, id) {
|
|
235
|
+
const w = await getSpace(owner, id);
|
|
236
|
+
const ids = w.tabs.flatMap((x) => ('frame' in x.source ? [x.source.frame] : []));
|
|
237
|
+
const hs = await heads(owner, ids);
|
|
238
|
+
return { ...w, frames: Object.fromEntries([...hs].map(([k, h]) => [k, metaOf(h)])) };
|
|
239
|
+
},
|
|
240
|
+
async patch(owner, id, p) {
|
|
241
|
+
const w = await getSpace(owner, id);
|
|
242
|
+
return saveSpace(owner, {
|
|
243
|
+
...w,
|
|
244
|
+
...(p.name ? { name: p.name } : {}),
|
|
245
|
+
...(p.app ? { app: p.app } : {}),
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
async remove(owner, id) {
|
|
249
|
+
const w = await getSpace(owner, id);
|
|
250
|
+
await db.run(`DELETE FROM ${t.spaces} WHERE owner = ? AND id = ?`, owner, id);
|
|
251
|
+
for (const x of w.tabs)
|
|
252
|
+
if ('frame' in x.source)
|
|
253
|
+
await dropFrameIfUnused(owner, x.source.frame);
|
|
254
|
+
},
|
|
255
|
+
/** A new tab from a recipe: runs it once, keeps the rows as a frame, adds the tab. */
|
|
256
|
+
async addTab(owner, id, p) {
|
|
257
|
+
const w = await getSpace(owner, id);
|
|
258
|
+
if (w.tabs.length >= MAX_TABS)
|
|
259
|
+
throw new FrameError(`${w.name} has ${MAX_TABS} tabs, the most: delete one first.`, 409);
|
|
260
|
+
const [{ n } = { n: 0 }] = await db.all(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM ${t.frames} WHERE owner = ? LIMIT ${MAX_FRAMES})`, owner);
|
|
261
|
+
if (Number(n) >= MAX_FRAMES)
|
|
262
|
+
throw new FrameError(`You keep ${MAX_FRAMES} tabs of data, the most: delete some first.`, 409);
|
|
263
|
+
const head = {
|
|
264
|
+
id: newId('fr'),
|
|
265
|
+
name: p.name,
|
|
266
|
+
columns: [],
|
|
267
|
+
rows: 0,
|
|
268
|
+
mode: 'live',
|
|
269
|
+
kind: 'snapshot',
|
|
270
|
+
at: now(),
|
|
271
|
+
origin: 'assistant',
|
|
272
|
+
script: p.recipe,
|
|
273
|
+
};
|
|
274
|
+
await runInto(owner, head, [], p.recipe, p.by ?? 'you', []);
|
|
275
|
+
const tab = {
|
|
276
|
+
id: newId('tb'),
|
|
277
|
+
name: p.name,
|
|
278
|
+
source: { frame: head.id },
|
|
279
|
+
config: null,
|
|
280
|
+
};
|
|
281
|
+
const saved = await saveSpace(owner, { ...w, tabs: [...w.tabs, tab] });
|
|
282
|
+
const hs = await heads(owner, saved.tabs.flatMap((x) => ('frame' in x.source ? [x.source.frame] : [])));
|
|
283
|
+
return {
|
|
284
|
+
workspace: {
|
|
285
|
+
...saved,
|
|
286
|
+
frames: Object.fromEntries([...hs].map(([k, h]) => [k, metaOf(h)])),
|
|
287
|
+
},
|
|
288
|
+
tab: tab.id,
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
async putTab(owner, id, tab, p) {
|
|
292
|
+
const w = await getSpace(owner, id);
|
|
293
|
+
if (!w.tabs.some((x) => x.id === tab))
|
|
294
|
+
throw new FrameError('No tab with this id.', 404);
|
|
295
|
+
return saveSpace(owner, {
|
|
296
|
+
...w,
|
|
297
|
+
tabs: w.tabs.map((x) => x.id === tab
|
|
298
|
+
? {
|
|
299
|
+
...x,
|
|
300
|
+
...(p.name ? { name: p.name } : {}),
|
|
301
|
+
...(p.config !== undefined ? { config: p.config } : {}),
|
|
302
|
+
}
|
|
303
|
+
: x),
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
async removeTab(owner, id, tab) {
|
|
307
|
+
const w = await getSpace(owner, id);
|
|
308
|
+
const gone = w.tabs.find((x) => x.id === tab);
|
|
309
|
+
if (!gone)
|
|
310
|
+
throw new FrameError('No tab with this id.', 404);
|
|
311
|
+
const saved = await saveSpace(owner, { ...w, tabs: w.tabs.filter((x) => x.id !== tab) });
|
|
312
|
+
if ('frame' in gone.source)
|
|
313
|
+
await dropFrameIfUnused(owner, gone.source.frame);
|
|
314
|
+
return saved;
|
|
315
|
+
},
|
|
316
|
+
/** Runs every tab's recipe again, one after another; a failed one leaves its last rows. */
|
|
317
|
+
async refreshAll(owner, id) {
|
|
318
|
+
const w = await getSpace(owner, id);
|
|
319
|
+
let ran = 0;
|
|
320
|
+
let rows = 0;
|
|
321
|
+
const failed = [];
|
|
322
|
+
for (const x of w.tabs) {
|
|
323
|
+
if (!('frame' in x.source))
|
|
324
|
+
continue;
|
|
325
|
+
try {
|
|
326
|
+
const d = await refresh(owner, x.source.frame);
|
|
327
|
+
ran++;
|
|
328
|
+
rows += d.meta.rows;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
failed.push(x.name);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { ran, failed, rows };
|
|
335
|
+
},
|
|
336
|
+
async frames(owner) {
|
|
337
|
+
const rows = await db.all(`SELECT head FROM ${t.frames} WHERE owner = ? LIMIT ${MAX_FRAMES}`, owner);
|
|
338
|
+
return {
|
|
339
|
+
frames: rows.map((r) => {
|
|
340
|
+
const h = parse(r.head, null);
|
|
341
|
+
return {
|
|
342
|
+
id: h.id,
|
|
343
|
+
name: h.name,
|
|
344
|
+
columns: h.columns,
|
|
345
|
+
at: h.at,
|
|
346
|
+
origin: h.origin ?? 'assistant',
|
|
347
|
+
};
|
|
348
|
+
}),
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
async frame(owner, id) {
|
|
352
|
+
return (await getFrame(owner, id)).data;
|
|
353
|
+
},
|
|
354
|
+
refresh,
|
|
355
|
+
async script(owner, id) {
|
|
356
|
+
return { script: (await getFrame(owner, id)).head.script };
|
|
357
|
+
},
|
|
358
|
+
/** A new recipe for a frame: kept only if it runs. */
|
|
359
|
+
async setScript(owner, id, script) {
|
|
360
|
+
const { head, data } = await getFrame(owner, id);
|
|
361
|
+
return runInto(owner, head, data.edits ?? [], script, 'you', data.rows);
|
|
362
|
+
},
|
|
363
|
+
async editCells(owner, id, edits) {
|
|
364
|
+
const { head, data } = await getFrame(owner, id);
|
|
365
|
+
const at = now();
|
|
366
|
+
const add = edits.map((e) => {
|
|
367
|
+
const col = head.columns.find((c) => c.key === e.column);
|
|
368
|
+
if (!col)
|
|
369
|
+
throw new FrameError(`No column ${e.column}.`);
|
|
370
|
+
if (e.row === undefined)
|
|
371
|
+
throw new FrameError('An edit names its row.');
|
|
372
|
+
return { column: e.column, row: e.row, value: coerce(col, e.value), at };
|
|
373
|
+
});
|
|
374
|
+
const all = dedupeEdits([...(data.edits ?? []), ...add]);
|
|
375
|
+
const put = applyEdits(head.columns, data.rows, add);
|
|
376
|
+
const next = { ...head, edited: all.length || undefined };
|
|
377
|
+
await writeFrame(owner, next, put.data, all);
|
|
378
|
+
return { meta: metaOf(next), rows: put.data, edits: all };
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.190",
|
|
4
4
|
"description": "Lumen's assistant and agent parts: a chat assistant's server (Claude tiers, tools over MCP, the web, hand off, spend caps), serving an MCP server from a Worker, read-only tool sets and metered tool calls.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"./agent": {
|
|
23
23
|
"types": "./dist/agent/index.d.ts",
|
|
24
24
|
"default": "./dist/agent/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./workspaces": {
|
|
27
|
+
"types": "./dist/workspaces/index.d.ts",
|
|
28
|
+
"default": "./dist/workspaces/index.js"
|
|
25
29
|
}
|
|
26
30
|
},
|
|
27
31
|
"files": [
|
|
@@ -48,8 +52,8 @@
|
|
|
48
52
|
"@ai-sdk/openai": "4.0.108",
|
|
49
53
|
"@anthropic-ai/sdk": "0.133.0",
|
|
50
54
|
"@cloudflare/think": "0.23.0",
|
|
51
|
-
"@columenlabs/lumen-ops": "0.1.
|
|
52
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
55
|
+
"@columenlabs/lumen-ops": "0.1.190",
|
|
56
|
+
"@columenlabs/lumen-ui-types": "0.1.190",
|
|
53
57
|
"agents": "0.32.0",
|
|
54
58
|
"ai": "7.0.162",
|
|
55
59
|
"just-bash": "3.6.0",
|