makersclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/bin/makersclaw.mjs +10 -0
- package/package.json +34 -0
- package/registry/blocks/board/README.md +130 -0
- package/registry/blocks/board/block.json +10 -0
- package/registry/blocks/board/board.jsx +435 -0
- package/registry/blocks/board/demo.jsx +50 -0
- package/registry/blocks/board/index.js +4 -0
- package/registry/blocks/job-panel/README.md +68 -0
- package/registry/blocks/job-panel/block.json +10 -0
- package/registry/blocks/job-panel/demo.jsx +75 -0
- package/registry/blocks/job-panel/index.js +3 -0
- package/registry/blocks/job-panel/job-panel.jsx +110 -0
- package/src/cli.mjs +301 -0
- package/src/fsutil.mjs +76 -0
- package/src/install.mjs +457 -0
- package/src/registry.mjs +191 -0
- package/templates/next/files/README.md +626 -0
- package/templates/next/files/app/auto-refresh.jsx +26 -0
- package/templates/next/files/app/error-relay.jsx +53 -0
- package/templates/next/files/app/error.jsx +44 -0
- package/templates/next/files/app/global-error.jsx +36 -0
- package/templates/next/files/app/globals.css +178 -0
- package/templates/next/files/app/layout.jsx +51 -0
- package/templates/next/files/app/not-found.jsx +23 -0
- package/templates/next/files/app/page.jsx +22 -0
- package/templates/next/files/app/s/[key]/page.jsx +47 -0
- package/templates/next/files/app/shell.jsx +64 -0
- package/templates/next/files/check/run.mjs +683 -0
- package/templates/next/files/components/parts/fixture.jsx +60 -0
- package/templates/next/files/components/parts/index.js +5 -0
- package/templates/next/files/components/parts/record-switcher.jsx +49 -0
- package/templates/next/files/components/ui/button.jsx +81 -0
- package/templates/next/files/components/ui/cx.js +4 -0
- package/templates/next/files/components/ui/depiction.jsx +66 -0
- package/templates/next/files/components/ui/field.jsx +126 -0
- package/templates/next/files/components/ui/icon.jsx +87 -0
- package/templates/next/files/components/ui/index.js +12 -0
- package/templates/next/files/components/ui/menu.jsx +123 -0
- package/templates/next/files/components/ui/notice.jsx +86 -0
- package/templates/next/files/components/ui/sheet.jsx +89 -0
- package/templates/next/files/components/ui/tabs.jsx +81 -0
- package/templates/next/files/lib/actions.js +187 -0
- package/templates/next/files/lib/claw.js +219 -0
- package/templates/next/files/lib/data.js +171 -0
- package/templates/next/files/lib/db.js +72 -0
- package/templates/next/files/lib/hooks.js +218 -0
- package/templates/next/files/lib/platform.js +59 -0
- package/templates/next/files/lib/sample.js +12 -0
- package/templates/next/files/middleware.js +42 -0
- package/templates/next/files/next.config.mjs +41 -0
- package/templates/next/files/package.json +19 -0
- package/templates/next/files/postcss.config.mjs +3 -0
- package/templates/next/files/schema.sql +22 -0
- package/templates/next/files/surfaces/README.md +32 -0
- package/templates/next/files/surfaces.js +53 -0
- package/templates/next/template.json +118 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import { artifactId, ident, query } from "./db";
|
|
3
|
+
import { markSample } from "./sample";
|
|
4
|
+
|
|
5
|
+
// SERVER reads. Import from server components and server actions only —
|
|
6
|
+
// this file pulls in next/headers, so a "use client" file cannot import it
|
|
7
|
+
// (the build says so). Every read returns { ..., error } and never throws:
|
|
8
|
+
// a missing table or an unreachable database becomes error text the
|
|
9
|
+
// surface can show, and console.error puts it in the log tail the
|
|
10
|
+
// platform agent reads.
|
|
11
|
+
|
|
12
|
+
const ORDER = /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(asc|desc)?\s*$/i;
|
|
13
|
+
|
|
14
|
+
/** "updated_at desc, id" → validated ORDER BY text. */
|
|
15
|
+
export function orderClause(orderBy, fallback) {
|
|
16
|
+
const parts = String(orderBy ?? fallback)
|
|
17
|
+
.split(",")
|
|
18
|
+
.map((p) => {
|
|
19
|
+
const m = ORDER.exec(p);
|
|
20
|
+
if (!m) throw new Error(`invalid orderBy: ${orderBy}`);
|
|
21
|
+
return `${ident(m[1])} ${m[2] ? m[2].toUpperCase() : "ASC"}`;
|
|
22
|
+
});
|
|
23
|
+
return parts.join(", ");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Dates and other non-JSON values become plain so rows can cross into
|
|
27
|
+
* client components. pg already returns bigint/numeric as strings. */
|
|
28
|
+
export function plain(row) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [k, v] of Object.entries(row)) {
|
|
31
|
+
if (v instanceof Date) out[k] = Number.isNaN(v.getTime()) ? null : v.toISOString();
|
|
32
|
+
else if (Buffer.isBuffer(v)) out[k] = v.toString("base64");
|
|
33
|
+
else out[k] = v;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// { column: value } → "WHERE ..." text; params is filled in place.
|
|
39
|
+
// null → IS NULL, an array → = ANY, anything else → equality.
|
|
40
|
+
function whereClause(filter, params) {
|
|
41
|
+
const where = [];
|
|
42
|
+
for (const [col, val] of Object.entries(filter ?? {})) {
|
|
43
|
+
if (val === null) {
|
|
44
|
+
where.push(`${ident(col)} IS NULL`);
|
|
45
|
+
} else if (Array.isArray(val)) {
|
|
46
|
+
params.push(val);
|
|
47
|
+
where.push(`${ident(col)} = ANY($${params.length})`);
|
|
48
|
+
} else {
|
|
49
|
+
params.push(val);
|
|
50
|
+
where.push(`${ident(col)} = $${params.length}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function failed(what, error) {
|
|
57
|
+
const message = error?.message ?? String(error);
|
|
58
|
+
console.error(`[data] could not read ${what}: ${message}`);
|
|
59
|
+
return message;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Rows of a source table. `filter` is { column: value }; `orderBy` is
|
|
64
|
+
* "updated_at desc" (default: idColumn desc); `limit` caps at 5000.
|
|
65
|
+
* → { rows, error }
|
|
66
|
+
*/
|
|
67
|
+
export async function loadRows({ source, idColumn = "id", orderBy, limit = 500, filter }) {
|
|
68
|
+
try {
|
|
69
|
+
const params = [];
|
|
70
|
+
const sql =
|
|
71
|
+
`SELECT * FROM ${ident(source)}` +
|
|
72
|
+
whereClause(filter, params) +
|
|
73
|
+
` ORDER BY ${orderClause(orderBy, `${idColumn} desc`)}` +
|
|
74
|
+
` LIMIT ${Math.max(1, Math.min(5000, Number(limit) || 500))}`;
|
|
75
|
+
const { rows } = await query(sql, params);
|
|
76
|
+
return { rows: rows.map(plain), error: null };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { rows: [], error: failed(source, error) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One row by id. `row` is null when there is none. → { row, error } */
|
|
83
|
+
export async function loadRow({ source, idColumn = "id", id }) {
|
|
84
|
+
try {
|
|
85
|
+
const { rows } = await query(`SELECT * FROM ${ident(source)} WHERE ${ident(idColumn)} = $1 LIMIT 1`, [id]);
|
|
86
|
+
return { row: rows[0] ? plain(rows[0]) : null, error: null };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { row: null, error: failed(source, error) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How many rows match `filter`. → { count, error } */
|
|
93
|
+
export async function countRows({ source, filter }) {
|
|
94
|
+
try {
|
|
95
|
+
const params = [];
|
|
96
|
+
const { rows } = await query(`SELECT count(*)::int AS n FROM ${ident(source)}${whereClause(filter, params)}`, params);
|
|
97
|
+
return { count: rows[0]?.n ?? 0, error: null };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { count: 0, error: failed(source, error) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Rows per distinct value of `column` (a state column, an owner…).
|
|
104
|
+
* NULL values count under the key "null". → { counts: { [value]: n }, error } */
|
|
105
|
+
export async function countBy({ source, column, filter }) {
|
|
106
|
+
try {
|
|
107
|
+
const params = [];
|
|
108
|
+
const { rows } = await query(
|
|
109
|
+
`SELECT ${ident(column)} AS v, count(*)::int AS n FROM ${ident(source)}${whereClause(filter, params)} GROUP BY 1 ORDER BY 2 DESC`,
|
|
110
|
+
params,
|
|
111
|
+
);
|
|
112
|
+
const counts = {};
|
|
113
|
+
for (const r of rows) counts[r.v === null ? "null" : String(r.v)] = r.n;
|
|
114
|
+
return { counts, error: null };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { counts: {}, error: failed(`${source}.${column}`, error) };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What happened, newest first: public.artifact_activity for this artifact
|
|
122
|
+
* (every lib/actions.js write and everything the agent recorded with the
|
|
123
|
+
* sql verb). `surface` narrows to one surface key; `since` (ISO or Date)
|
|
124
|
+
* to rows after that time. Rows: { id, at, action, surface, row_ref,
|
|
125
|
+
* detail, data, by }. → { rows, error }
|
|
126
|
+
*/
|
|
127
|
+
export async function loadActivity({ limit = 100, surface, since } = {}) {
|
|
128
|
+
const id = artifactId();
|
|
129
|
+
if (!id) {
|
|
130
|
+
return { rows: [], error: failed("public.artifact_activity", "no CLAW_ARTIFACT_ID; product_run sets it") };
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const params = [id];
|
|
134
|
+
let where = `WHERE artifact_id = $1`;
|
|
135
|
+
if (surface) {
|
|
136
|
+
params.push(String(surface));
|
|
137
|
+
where += ` AND surface = $${params.length}`;
|
|
138
|
+
}
|
|
139
|
+
if (since) {
|
|
140
|
+
params.push(since instanceof Date ? since.toISOString() : String(since));
|
|
141
|
+
where += ` AND at > $${params.length}::timestamptz`;
|
|
142
|
+
}
|
|
143
|
+
params.push(Math.max(1, Math.min(1000, Number(limit) || 100)));
|
|
144
|
+
const { rows } = await query(
|
|
145
|
+
`SELECT id, at, action, surface, row_ref, detail, data, by
|
|
146
|
+
FROM public.artifact_activity ${where} ORDER BY at DESC, id DESC LIMIT $${params.length}`,
|
|
147
|
+
params,
|
|
148
|
+
);
|
|
149
|
+
return { rows: rows.map(plain), error: null };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return { rows: [], error: failed("public.artifact_activity", error) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Sample rows get stable ids and a `_sample: true` marker (lib/sample.js,
|
|
156
|
+
* pure — the Fixture part uses it in the browser). Kept here so server
|
|
157
|
+
* code keeps one import. */
|
|
158
|
+
export { markSample };
|
|
159
|
+
|
|
160
|
+
/** The surface key of the current request (middleware.js sets it from
|
|
161
|
+
* /s/<key>), unless the caller names one — a string, or the page's
|
|
162
|
+
* `surface` object. */
|
|
163
|
+
export async function currentSurface(explicit) {
|
|
164
|
+
if (typeof explicit === "string" && explicit) return explicit;
|
|
165
|
+
if (explicit && typeof explicit === "object" && typeof explicit.key === "string") return explicit.key;
|
|
166
|
+
try {
|
|
167
|
+
return (await headers()).get("x-claw-surface") || null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Pool } from "pg";
|
|
2
|
+
|
|
3
|
+
// One pool per process; DATABASE_URL comes from this directory's .env,
|
|
4
|
+
// written by app_database and sourced by product_run at start.
|
|
5
|
+
const pool =
|
|
6
|
+
globalThis._pgPool ??
|
|
7
|
+
new Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
|
|
8
|
+
globalThis._pgPool = pool;
|
|
9
|
+
|
|
10
|
+
export const query = (text, params) => pool.query(text, params);
|
|
11
|
+
|
|
12
|
+
/** The artifact this app belongs to. product_run sets it from the folder
|
|
13
|
+
* the app runs in; every activity row keys on it, and so does the
|
|
14
|
+
* Activity tab the OS draws on the app window. */
|
|
15
|
+
export function artifactId() {
|
|
16
|
+
return process.env.CLAW_ARTIFACT_ID || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
20
|
+
|
|
21
|
+
/** Quote a table or column name for SQL: "investors" → "investors",
|
|
22
|
+
* "public.investors" → "public"."investors". Anything else throws, so a
|
|
23
|
+
* typo in a surface config can never reach the database as SQL. */
|
|
24
|
+
export function ident(name) {
|
|
25
|
+
const parts = String(name ?? "").split(".");
|
|
26
|
+
if (!parts.length || parts.length > 2 || !parts.every((p) => IDENT.test(p))) {
|
|
27
|
+
throw new Error(`invalid identifier: ${name}`);
|
|
28
|
+
}
|
|
29
|
+
return parts.map((p) => `"${p}"`).join(".");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Record what happened, for the Activity tab the OS draws on the app
|
|
34
|
+
* window (and any surface that reads the table with loadActivity). Every
|
|
35
|
+
* lib/actions.js write calls this; the artifact's agent writes the same
|
|
36
|
+
* table with the sql verb.
|
|
37
|
+
* Never throws — an activity line must not fail the write it describes.
|
|
38
|
+
*
|
|
39
|
+
* action found, drafted, sent, replied, skipped, edited, moved, added…
|
|
40
|
+
* surface the surface key the row belongs to (the OS tab shows its
|
|
41
|
+
* label; a link into the app is /s/<surface>?row=<row_ref>)
|
|
42
|
+
* row_ref the id in that surface's source table
|
|
43
|
+
* detail one sentence
|
|
44
|
+
* data anything worth keeping (jsonb)
|
|
45
|
+
* by 'agent' | 'user:<email>' | 'run:<id>'
|
|
46
|
+
*/
|
|
47
|
+
export async function logActivity({ action, surface, row_ref, detail, data, by }) {
|
|
48
|
+
const id = artifactId();
|
|
49
|
+
if (!id) {
|
|
50
|
+
console.warn(`[activity] not recorded (no CLAW_ARTIFACT_ID): ${action} ${detail ?? ""}`);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await query(
|
|
55
|
+
`INSERT INTO public.artifact_activity (artifact_id, action, surface, row_ref, detail, data, by)
|
|
56
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)`,
|
|
57
|
+
[
|
|
58
|
+
id,
|
|
59
|
+
String(action || "edited"),
|
|
60
|
+
surface ?? null,
|
|
61
|
+
row_ref == null ? null : String(row_ref),
|
|
62
|
+
detail ?? null,
|
|
63
|
+
JSON.stringify(data ?? {}),
|
|
64
|
+
by || "user:unknown",
|
|
65
|
+
],
|
|
66
|
+
);
|
|
67
|
+
return true;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error(`[activity] not recorded: ${String(error)}`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { claw, publishStatus } from "./claw";
|
|
5
|
+
import { cancelJob, startJob } from "./platform";
|
|
6
|
+
|
|
7
|
+
// The React side of the _claw API, without any look: fetching, polling,
|
|
8
|
+
// streaming and cleanup. A surface renders what comes back and owns every
|
|
9
|
+
// pixel. "use client" only: the _claw API is browser-side.
|
|
10
|
+
//
|
|
11
|
+
// There is no chat, runs or activity hook here, and there will not be:
|
|
12
|
+
// those are STANDING surfaces and the OS draws them as tabs on the app
|
|
13
|
+
// window (docs/ARTIFACT-CONTRACTS.md §9). `useJob` is the other thing —
|
|
14
|
+
// work a founder commissions from inside the app, which is not a surface
|
|
15
|
+
// at all: it opens because they pressed something and it closes when the
|
|
16
|
+
// work lands (SOP §2.3, ARTIFACT-CONTRACTS §9f).
|
|
17
|
+
//
|
|
18
|
+
// While the gateway has no _claw routes yet (`unavailable`), polls back
|
|
19
|
+
// off to once a minute so a preview that is still being wired does not
|
|
20
|
+
// hammer 404s.
|
|
21
|
+
|
|
22
|
+
const BACKOFF_MS = 60000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* GET one _claw route. Fetches on mount, again every `every` ms while the
|
|
26
|
+
* tab is visible, and on reload(). A failed poll keeps the last data and
|
|
27
|
+
* sets `error`. Cleared on unmount.
|
|
28
|
+
* → { data, error, loading, unavailable, reload }
|
|
29
|
+
*/
|
|
30
|
+
export function useClaw(path, { every, enabled = true } = {}) {
|
|
31
|
+
const [state, setState] = useState({ data: null, error: null, loading: Boolean(enabled), unavailable: false });
|
|
32
|
+
const [tick, setTick] = useState(0);
|
|
33
|
+
const lastRef = useRef(0);
|
|
34
|
+
const unavailableRef = useRef(false);
|
|
35
|
+
const reload = useCallback(() => setTick((t) => t + 1), []);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!enabled) return undefined;
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
let alive = true;
|
|
41
|
+
const load = async () => {
|
|
42
|
+
lastRef.current = Date.now();
|
|
43
|
+
const r = await claw(path, { signal: controller.signal });
|
|
44
|
+
if (!alive || r.aborted) return;
|
|
45
|
+
unavailableRef.current = Boolean(r.unavailable);
|
|
46
|
+
if (r.ok) setState({ data: r.data, error: null, loading: false, unavailable: false });
|
|
47
|
+
else setState((s) => ({ ...s, error: r.error, loading: false, unavailable: Boolean(r.unavailable) }));
|
|
48
|
+
};
|
|
49
|
+
load();
|
|
50
|
+
const id = every
|
|
51
|
+
? setInterval(() => {
|
|
52
|
+
if (document.visibilityState !== "visible") return;
|
|
53
|
+
if (unavailableRef.current && Date.now() - lastRef.current < BACKOFF_MS) return;
|
|
54
|
+
load();
|
|
55
|
+
}, every)
|
|
56
|
+
: null;
|
|
57
|
+
return () => {
|
|
58
|
+
alive = false;
|
|
59
|
+
controller.abort();
|
|
60
|
+
if (id) clearInterval(id);
|
|
61
|
+
};
|
|
62
|
+
}, [path, every, enabled, tick]);
|
|
63
|
+
|
|
64
|
+
return { ...state, reload };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Who this app is (_claw/me). Also publishes the status onto <html>
|
|
69
|
+
* so reportError can tell a preview fault from a runtime one.
|
|
70
|
+
* → { artifact, user, blueprint, loading, error, unavailable, reload }
|
|
71
|
+
*/
|
|
72
|
+
export function useArtifact() {
|
|
73
|
+
const { data, ...rest } = useClaw("me");
|
|
74
|
+
const artifact = data?.artifact ?? null;
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
publishStatus(artifact?.status);
|
|
77
|
+
}, [artifact?.status]);
|
|
78
|
+
return { artifact, user: data?.user ?? null, blueprint: data?.blueprint ?? null, ...rest };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A settled tool call closes the LAST running step with that name, not
|
|
82
|
+
* the first: two calls to the same tool in one turn settle in order. */
|
|
83
|
+
function settleStep(steps, name, status) {
|
|
84
|
+
const next = [...steps];
|
|
85
|
+
for (let i = next.length - 1; i >= 0; i -= 1) {
|
|
86
|
+
if (next[i].name === name && next[i].status === "running") {
|
|
87
|
+
next[i] = { ...next[i], status };
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const IDLE = {
|
|
95
|
+
running: false,
|
|
96
|
+
steps: [],
|
|
97
|
+
text: "",
|
|
98
|
+
error: null,
|
|
99
|
+
sessionId: null,
|
|
100
|
+
progress: 0,
|
|
101
|
+
finishedAt: null,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Commission work and follow it: the job half of SOP §2.3. One job at a
|
|
106
|
+
* time per hook — call it once per place the founder can start something.
|
|
107
|
+
*
|
|
108
|
+
* const job = useJob();
|
|
109
|
+
* <button onClick={() => job.start("Draft a launch checklist for …")}>
|
|
110
|
+
*
|
|
111
|
+
* State, for the panel the surface draws:
|
|
112
|
+
* running a job is in flight
|
|
113
|
+
* steps [{ name, label, status }] — `label` is the founder's
|
|
114
|
+
* phrase ("Searching the web"), `status` running | done |
|
|
115
|
+
* failed. Show labels; the raw name is there for debugging.
|
|
116
|
+
* text what the agent has written so far, growing. This is its
|
|
117
|
+
* account of the work, NOT the deliverable — the deliverable
|
|
118
|
+
* is rows on a surface.
|
|
119
|
+
* progress bumps every time a step settles. Feed it to a data hook's
|
|
120
|
+
* reload so rows appear as they land:
|
|
121
|
+
* useEffect(() => { reload(); }, [job.progress]);
|
|
122
|
+
* error a message, or null
|
|
123
|
+
* sessionId the thread. Pass it back to start() to refine what this
|
|
124
|
+
* job produced; keep it on the object's row and the founder
|
|
125
|
+
* can pick the conversation up where the thing lives.
|
|
126
|
+
* finishedAt ISO time the last job settled, or null
|
|
127
|
+
*
|
|
128
|
+
* Actions: start(message, { sessionId }) · cancel() · reset().
|
|
129
|
+
*
|
|
130
|
+
* The panel this drives must not exist when nothing is running — that is
|
|
131
|
+
* §2.3's test, and a standing one is the monitoring console §2.7 warns
|
|
132
|
+
* about. On delivery, close it and let the rows speak.
|
|
133
|
+
*/
|
|
134
|
+
export function useJob() {
|
|
135
|
+
const [state, setState] = useState(IDLE);
|
|
136
|
+
const abortRef = useRef(null);
|
|
137
|
+
const sessionRef = useRef(null);
|
|
138
|
+
const aliveRef = useRef(true);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
aliveRef.current = true;
|
|
142
|
+
return () => {
|
|
143
|
+
aliveRef.current = false;
|
|
144
|
+
abortRef.current?.abort();
|
|
145
|
+
};
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
const start = useCallback(async (message, { sessionId } = {}) => {
|
|
149
|
+
const text = String(message ?? "").trim();
|
|
150
|
+
if (!text) return { ok: false, error: "nothing to ask" };
|
|
151
|
+
abortRef.current?.abort();
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
abortRef.current = controller;
|
|
154
|
+
const thread = sessionId ?? null;
|
|
155
|
+
sessionRef.current = thread;
|
|
156
|
+
setState({ ...IDLE, running: true, sessionId: thread });
|
|
157
|
+
|
|
158
|
+
const onEvent = ({ event, data }) => {
|
|
159
|
+
if (!aliveRef.current) return;
|
|
160
|
+
if (event === "session") {
|
|
161
|
+
sessionRef.current = data?.session_id ?? sessionRef.current;
|
|
162
|
+
setState((s) => ({ ...s, sessionId: sessionRef.current }));
|
|
163
|
+
} else if (event === "delta") {
|
|
164
|
+
setState((s) => ({ ...s, text: s.text + (data?.text ?? "") }));
|
|
165
|
+
} else if (event === "tool") {
|
|
166
|
+
const name = data?.name ?? "tool";
|
|
167
|
+
const label = data?.label ?? name;
|
|
168
|
+
setState((s) =>
|
|
169
|
+
data?.status === "running"
|
|
170
|
+
? { ...s, steps: [...s.steps, { name, label, status: "running" }] }
|
|
171
|
+
: {
|
|
172
|
+
...s,
|
|
173
|
+
steps: settleStep(s.steps, name, data?.status ?? "done"),
|
|
174
|
+
progress: s.progress + 1,
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
} else if (event === "done") {
|
|
178
|
+
setState((s) => ({
|
|
179
|
+
...s,
|
|
180
|
+
running: false,
|
|
181
|
+
text: data?.text || s.text,
|
|
182
|
+
progress: s.progress + 1,
|
|
183
|
+
finishedAt: new Date().toISOString(),
|
|
184
|
+
}));
|
|
185
|
+
} else if (event === "error") {
|
|
186
|
+
setState((s) => ({ ...s, running: false, error: data?.message ?? "the agent stopped" }));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = await startJob(text, {
|
|
191
|
+
sessionId: thread ?? undefined,
|
|
192
|
+
onEvent,
|
|
193
|
+
signal: controller.signal,
|
|
194
|
+
});
|
|
195
|
+
if (!aliveRef.current) return result;
|
|
196
|
+
// A stream that ends without `done` (a dropped connection, a 4xx
|
|
197
|
+
// before the first frame) must still leave the panel closable.
|
|
198
|
+
if (!result.ok && !result.aborted)
|
|
199
|
+
setState((s) => ({ ...s, running: false, error: s.error ?? result.error }));
|
|
200
|
+
else setState((s) => (s.running ? { ...s, running: false, finishedAt: new Date().toISOString() } : s));
|
|
201
|
+
return result;
|
|
202
|
+
}, []);
|
|
203
|
+
|
|
204
|
+
const cancel = useCallback(async () => {
|
|
205
|
+
abortRef.current?.abort();
|
|
206
|
+
const id = sessionRef.current;
|
|
207
|
+
setState((s) => ({ ...s, running: false, finishedAt: new Date().toISOString() }));
|
|
208
|
+
if (id) await cancelJob(id);
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
const reset = useCallback(() => {
|
|
212
|
+
abortRef.current?.abort();
|
|
213
|
+
sessionRef.current = null;
|
|
214
|
+
setState(IDLE);
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
return { ...state, start, cancel, reset };
|
|
218
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { claw, clawStream, reportError } from "./claw";
|
|
2
|
+
|
|
3
|
+
// Typed wrappers over the _claw API (lib/claw.js), one per route an app
|
|
4
|
+
// has a reason to call. BROWSER only — the grant cookie is the identity —
|
|
5
|
+
// so call these from "use client" files; lib/hooks.js wraps them.
|
|
6
|
+
//
|
|
7
|
+
// Every call resolves { ok, status, data } or { ok: false, status, error,
|
|
8
|
+
// unavailable }; nothing throws. `unavailable: true` (404 or no network)
|
|
9
|
+
// means the gateway has no such route yet — render a quiet "not connected
|
|
10
|
+
// yet" state, not an error.
|
|
11
|
+
//
|
|
12
|
+
// Three things live here: who the app is (`me`), the error relay
|
|
13
|
+
// (`reportError`), and COMMISSIONING — `startJob` / `cancelJob`, the work
|
|
14
|
+
// a founder asks for from inside the app (SOP §2.3). Monitoring is still
|
|
15
|
+
// not an app's business: the OS draws Agent, Runs and Activity as tabs on
|
|
16
|
+
// the window and the title bar holds the run controls, so nothing here
|
|
17
|
+
// lists chats, runs or activity. `claw(path, opts)` and `clawStream` in
|
|
18
|
+
// lib/claw.js are the generic doors for the rest of §5.
|
|
19
|
+
|
|
20
|
+
/** GET _claw/me → { artifact: { id, name, vehicle, status, home },
|
|
21
|
+
* user: { id, email }, blueprint } */
|
|
22
|
+
export function me() {
|
|
23
|
+
return claw("me");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Commission work: POST _claw/chat as a stream, and call `onEvent` for
|
|
28
|
+
* every frame — `session` (once, first, carrying the thread's id),
|
|
29
|
+
* `delta` ({ text } as the answer is written), `tool` ({ name, label,
|
|
30
|
+
* status }: `label` is the founder-facing phrase, `status` running | done
|
|
31
|
+
* | failed), `done` ({ text }), `error` ({ message }).
|
|
32
|
+
*
|
|
33
|
+
* Pass `sessionId` to continue a thread the app already holds — refining
|
|
34
|
+
* the thing this job produced. Leave it out and the job gets a thread of
|
|
35
|
+
* its own, so two jobs never land in one conversation.
|
|
36
|
+
*
|
|
37
|
+
* The deliverable is NOT the text: the agent writes rows, and the surface
|
|
38
|
+
* shows them. Treat `delta` as what it is doing, not as the output.
|
|
39
|
+
*/
|
|
40
|
+
export function startJob(message, { sessionId, onEvent, signal } = {}) {
|
|
41
|
+
return clawStream(
|
|
42
|
+
"chat",
|
|
43
|
+
sessionId ? { message, session_id: sessionId } : { message, job: true },
|
|
44
|
+
onEvent ?? (() => {}),
|
|
45
|
+
{ signal },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** POST _claw/cancel — stop a job's turn. The rows it already wrote stay;
|
|
50
|
+
* so does its run in the Runs tab. */
|
|
51
|
+
export function cancelJob(sessionId) {
|
|
52
|
+
return claw("cancel", { method: "POST", body: { session_id: sessionId } });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { reportError };
|
|
56
|
+
|
|
57
|
+
export const platform = { me, startJob, cancelJob, reportError };
|
|
58
|
+
|
|
59
|
+
export default platform;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Pure, importable from either side (lib/data.js re-exports it for server
|
|
2
|
+
// code; components/parts/fixture.jsx uses it in the browser).
|
|
3
|
+
|
|
4
|
+
/** Sample rows get stable ids and a `_sample: true` marker, so a surface
|
|
5
|
+
* can label them until real rows exist and refuse to edit them. */
|
|
6
|
+
export function markSample(sample, idColumn = "id") {
|
|
7
|
+
return (sample ?? []).map((r, i) => ({
|
|
8
|
+
...r,
|
|
9
|
+
[idColumn]: r[idColumn] ?? `sample-${i + 1}`,
|
|
10
|
+
_sample: true,
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
// The only door is the Makersclaw dashboard proxy, which stamps this token
|
|
4
|
+
// on every request. The sandbox port itself is public, so without this
|
|
5
|
+
// check anyone with the URL could read the founder's data.
|
|
6
|
+
//
|
|
7
|
+
// It also tells the server which surface a request is for: /s/<key> becomes
|
|
8
|
+
// the x-claw-surface request header, so lib/data.js and lib/actions.js can
|
|
9
|
+
// stamp activity rows with the surface without every surface file having
|
|
10
|
+
// to pass its own key down.
|
|
11
|
+
//
|
|
12
|
+
// The theme: the OS window opens the app with ?theme=light|dark to match
|
|
13
|
+
// the desk, and the product proxy carries it as the x-claw-theme header
|
|
14
|
+
// on every later request (the browser's cookies never reach the app).
|
|
15
|
+
// ?theme= on any URL wins for that request and sets the claw_theme cookie
|
|
16
|
+
// too, so the app keeps its theme when run without the proxy (local
|
|
17
|
+
// `next dev`). app/layout.jsx reads header, then cookie, onto
|
|
18
|
+
// <html data-theme>.
|
|
19
|
+
const THEMES = new Set(["light", "dark"]);
|
|
20
|
+
|
|
21
|
+
export function middleware(request) {
|
|
22
|
+
const expected = process.env.CLAW_PRODUCT_TOKEN;
|
|
23
|
+
if (expected && request.headers.get("x-claw-product-token") !== expected) {
|
|
24
|
+
return new NextResponse("Open this app from your Makersclaw dashboard.", {
|
|
25
|
+
status: 401,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const headers = new Headers(request.headers);
|
|
29
|
+
headers.delete("x-claw-surface");
|
|
30
|
+
const m = /^\/s\/([^/?#]+)/.exec(request.nextUrl.pathname);
|
|
31
|
+
if (m) headers.set("x-claw-surface", decodeURIComponent(m[1]));
|
|
32
|
+
const theme = request.nextUrl.searchParams.get("theme");
|
|
33
|
+
const themed = Boolean(theme && THEMES.has(theme));
|
|
34
|
+
if (themed) headers.set("x-claw-theme", theme);
|
|
35
|
+
const response = NextResponse.next({ request: { headers } });
|
|
36
|
+
if (themed) {
|
|
37
|
+
response.cookies.set("claw_theme", theme, { path: "/", maxAge: 60 * 60 * 24 * 365, sameSite: "lax" });
|
|
38
|
+
}
|
|
39
|
+
return response;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const config = { matcher: "/:path*" };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The dashboard reaches this app at /p/<label>/ through the workspace proxy,
|
|
2
|
+
// so every link and asset must carry that prefix. product_run sets
|
|
3
|
+
// CLAW_BASE_PATH at build AND start time; leave it unset to run at /.
|
|
4
|
+
//
|
|
5
|
+
// `env` inlines the same value into the client bundle: lib/claw.js needs it
|
|
6
|
+
// to address the _claw API from the browser. The <html data-claw-base-path>
|
|
7
|
+
// attribute app/layout.jsx renders is the runtime copy and is read first, so
|
|
8
|
+
// a start under a different prefix than the build still resolves.
|
|
9
|
+
//
|
|
10
|
+
// allowedOrigins is NOT optional here, and it is not yours to edit. Next 15
|
|
11
|
+
// refuses a Server Action whose `Origin` header disagrees with
|
|
12
|
+
// `x-forwarded-host` — and in this stack they always disagree, because the
|
|
13
|
+
// sandbox ingress rewrites `x-forwarded-host` to its own vercel.run name
|
|
14
|
+
// while the browser is on the product host. Without this the app loads
|
|
15
|
+
// perfectly and its FIRST SAVE fails, which reads like a broken button
|
|
16
|
+
// rather than a proxy policy. product_run derives the value
|
|
17
|
+
// (src/lib/products.ts productAllowedOrigins): the product's own FQDN in
|
|
18
|
+
// production, the dashboard's dev origin under a path prefix. Hardcoding a
|
|
19
|
+
// localhost origin here ships an app that works in dev and refuses every
|
|
20
|
+
// write in production.
|
|
21
|
+
const allowedOrigins = (process.env.CLAW_ALLOWED_ORIGINS || "")
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((o) => o.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
|
|
26
|
+
// `npm run check` builds into its own folder (CLAW_DIST_DIR=.next-check,
|
|
27
|
+
// check/run.mjs) so it never touches `.next`: in the sandbox the running
|
|
28
|
+
// dev server owns that folder as another user, and a second `next build`
|
|
29
|
+
// there dies with EACCES on .next/trace before it starts.
|
|
30
|
+
const nextConfig = {
|
|
31
|
+
basePath: process.env.CLAW_BASE_PATH || "",
|
|
32
|
+
distDir: process.env.CLAW_DIST_DIR || ".next",
|
|
33
|
+
env: {
|
|
34
|
+
CLAW_BASE_PATH: process.env.CLAW_BASE_PATH || "",
|
|
35
|
+
},
|
|
36
|
+
...(allowedOrigins.length > 0
|
|
37
|
+
? { experimental: { serverActions: { allowedOrigins } } }
|
|
38
|
+
: {}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default nextConfig;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "artifact",
|
|
3
|
+
"private": true,
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "next dev",
|
|
6
|
+
"build": "next build",
|
|
7
|
+
"start": "next start",
|
|
8
|
+
"check": "node check/run.mjs",
|
|
9
|
+
"test": "node check/run.mjs"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@tailwindcss/postcss": "^4",
|
|
13
|
+
"next": "^15.4.0",
|
|
14
|
+
"pg": "^8.16.0",
|
|
15
|
+
"react": "^19.1.0",
|
|
16
|
+
"react-dom": "^19.1.0",
|
|
17
|
+
"tailwindcss": "^4"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Reference only. artifact_scaffold creates this table in the workspace
|
|
2
|
+
-- database when it copies the template; nothing in the app runs this file.
|
|
3
|
+
-- (docs/ARTIFACT-CONTRACTS.md §4)
|
|
4
|
+
--
|
|
5
|
+
-- Every write the app makes (lib/actions.js → lib/db.js logActivity) and
|
|
6
|
+
-- every action the artifact's agent takes (its brief tells it to insert
|
|
7
|
+
-- here with the sql verb) lands in this table, keyed by the artifact id
|
|
8
|
+
-- product_run passes as CLAW_ARTIFACT_ID. The Activity tab the OS draws on
|
|
9
|
+
-- the app window reads it (§9b), and so can any surface via loadActivity.
|
|
10
|
+
|
|
11
|
+
create table if not exists public.artifact_activity (
|
|
12
|
+
id bigserial primary key,
|
|
13
|
+
artifact_id uuid not null,
|
|
14
|
+
at timestamptz not null default now(),
|
|
15
|
+
action text not null, -- found, drafted, sent, replied, skipped, edited…
|
|
16
|
+
surface text, -- surface key
|
|
17
|
+
row_ref text, -- id in the surface's source table
|
|
18
|
+
detail text, -- one sentence
|
|
19
|
+
data jsonb not null default '{}'::jsonb,
|
|
20
|
+
by text not null default 'agent' -- 'agent' | 'user:<email>' | 'run:<id>'
|
|
21
|
+
);
|
|
22
|
+
create index if not exists artifact_activity_artifact_at on public.artifact_activity (artifact_id, at desc);
|