@mugwork/mug 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/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/explorer.js +3 -0
- package/dist/packages/email-template/src/email-template.d.ts +18 -0
- package/dist/packages/email-template/src/email-template.js +74 -0
- package/dist/packages/email-template/src/index.d.ts +1 -0
- package/dist/packages/email-template/src/index.js +1 -0
- package/dist/packages/surface-renderer/src/form-renderer.d.ts +117 -0
- package/dist/packages/surface-renderer/src/form-renderer.js +719 -0
- package/dist/packages/surface-renderer/src/index.d.ts +4 -0
- package/dist/packages/surface-renderer/src/index.js +2 -0
- package/dist/packages/surface-renderer/src/portal-renderer.d.ts +177 -0
- package/dist/packages/surface-renderer/src/portal-renderer.js +1089 -0
- package/dist/packages/surface-renderer/src/workspace-home.d.ts +46 -0
- package/dist/packages/surface-renderer/src/workspace-home.js +345 -0
- package/dist/runtime/agent-types.d.ts +48 -0
- package/dist/runtime/agent-types.js +3 -0
- package/dist/runtime/ai-router.d.ts +32 -0
- package/dist/runtime/ai-router.js +112 -0
- package/dist/runtime/app.d.ts +6 -0
- package/dist/runtime/app.js +399 -0
- package/dist/runtime/chunker.d.ts +6 -0
- package/dist/runtime/chunker.js +30 -0
- package/dist/runtime/context.d.ts +115 -0
- package/dist/runtime/context.js +440 -0
- package/dist/runtime/do/workspace-database.d.ts +10 -0
- package/dist/runtime/do/workspace-database.js +199 -0
- package/dist/runtime/form-types.d.ts +143 -0
- package/dist/runtime/form-types.js +1 -0
- package/dist/runtime/runtime.d.ts +9 -0
- package/dist/runtime/runtime.js +7 -0
- package/dist/runtime/source-types.d.ts +15 -0
- package/dist/runtime/source-types.js +1 -0
- package/dist/runtime/source.d.ts +70 -0
- package/dist/runtime/source.js +21 -0
- package/dist/runtime/sync-runtime.d.ts +10 -0
- package/dist/runtime/sync-runtime.js +185 -0
- package/dist/runtime/types.d.ts +21 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/workflow-entrypoint.d.ts +31 -0
- package/dist/runtime/workflow-entrypoint.js +1297 -0
- package/dist/runtime/workflow.d.ts +285 -0
- package/dist/runtime/workflow.js +1008 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44116 -0
- package/dist/src/commands/ai-gateway-route.d.ts +24 -0
- package/dist/src/commands/ai-gateway-route.js +192 -0
- package/dist/src/commands/auth.d.ts +1 -0
- package/dist/src/commands/auth.js +42 -0
- package/dist/src/commands/billing.d.ts +6 -0
- package/dist/src/commands/billing.js +76 -0
- package/dist/src/commands/brain.d.ts +1 -0
- package/dist/src/commands/brain.js +194 -0
- package/dist/src/commands/demo.d.ts +12 -0
- package/dist/src/commands/demo.js +147 -0
- package/dist/src/commands/deploy.d.ts +1 -0
- package/dist/src/commands/deploy.js +1052 -0
- package/dist/src/commands/dev.d.ts +14 -0
- package/dist/src/commands/dev.js +2818 -0
- package/dist/src/commands/form.d.ts +8 -0
- package/dist/src/commands/form.js +396 -0
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.js +139 -0
- package/dist/src/commands/issue.d.ts +7 -0
- package/dist/src/commands/issue.js +191 -0
- package/dist/src/commands/login.d.ts +9 -0
- package/dist/src/commands/login.js +163 -0
- package/dist/src/commands/logs.d.ts +8 -0
- package/dist/src/commands/logs.js +113 -0
- package/dist/src/commands/portal.d.ts +2 -0
- package/dist/src/commands/portal.js +111 -0
- package/dist/src/commands/pull.d.ts +3 -0
- package/dist/src/commands/pull.js +184 -0
- package/dist/src/commands/push.d.ts +4 -0
- package/dist/src/commands/push.js +183 -0
- package/dist/src/commands/run.d.ts +6 -0
- package/dist/src/commands/run.js +91 -0
- package/dist/src/commands/secret.d.ts +7 -0
- package/dist/src/commands/secret.js +105 -0
- package/dist/src/commands/shutdown.d.ts +1 -0
- package/dist/src/commands/shutdown.js +46 -0
- package/dist/src/commands/sql.d.ts +8 -0
- package/dist/src/commands/sql.js +142 -0
- package/dist/src/commands/status.d.ts +5 -0
- package/dist/src/commands/status.js +39 -0
- package/dist/src/commands/sync.d.ts +7 -0
- package/dist/src/commands/sync.js +991 -0
- package/dist/src/commands/usage.d.ts +6 -0
- package/dist/src/commands/usage.js +78 -0
- package/dist/src/commands/webhooks.d.ts +1 -0
- package/dist/src/commands/webhooks.js +102 -0
- package/dist/src/commands/workspace.d.ts +23 -0
- package/dist/src/commands/workspace.js +590 -0
- package/dist/src/connector-migration.d.ts +20 -0
- package/dist/src/connector-migration.js +43 -0
- package/dist/src/connector-parser.d.ts +14 -0
- package/dist/src/connector-parser.js +94 -0
- package/dist/src/connector-service/discover.d.ts +37 -0
- package/dist/src/connector-service/discover.js +79 -0
- package/dist/src/connector-service/gather.d.ts +22 -0
- package/dist/src/connector-service/gather.js +89 -0
- package/dist/src/connector-service/init.d.ts +14 -0
- package/dist/src/connector-service/init.js +109 -0
- package/dist/src/connector-service/scaffold.d.ts +17 -0
- package/dist/src/connector-service/scaffold.js +194 -0
- package/dist/src/connector-service/spec-storage.d.ts +8 -0
- package/dist/src/connector-service/spec-storage.js +48 -0
- package/dist/src/connector-service/types.d.ts +57 -0
- package/dist/src/connector-service/types.js +2 -0
- package/dist/src/connector-service/verify.d.ts +24 -0
- package/dist/src/connector-service/verify.js +575 -0
- package/dist/src/email-template.d.ts +2 -0
- package/dist/src/email-template.js +1 -0
- package/dist/src/manifest.d.ts +31 -0
- package/dist/src/manifest.js +25 -0
- package/dist/src/mug-icon.d.ts +1 -0
- package/dist/src/mug-icon.js +12 -0
- package/dist/src/slack-manifest.d.ts +119 -0
- package/dist/src/slack-manifest.js +163 -0
- package/dist/src/source-migration.d.ts +20 -0
- package/dist/src/source-migration.js +43 -0
- package/dist/src/surface-renderer.d.ts +5 -0
- package/dist/src/surface-renderer.js +3 -0
- package/dist/src/templates.d.ts +3 -0
- package/dist/src/templates.js +48 -0
- package/dist/src/version-check.d.ts +1 -0
- package/dist/src/version-check.js +28 -0
- package/dist/src/workflow-parser.d.ts +95 -0
- package/dist/src/workflow-parser.js +526 -0
- package/dist/worker/src/agent-types.d.ts +27 -0
- package/dist/worker/src/agent-types.js +3 -0
- package/dist/worker/src/source-types.d.ts +14 -0
- package/dist/worker/src/source-types.js +1 -0
- package/package.json +90 -0
- package/src/data/model-capabilities.json +171 -0
|
@@ -0,0 +1,2818 @@
|
|
|
1
|
+
import { checkCliVersion } from "../version-check.js";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, watch, mkdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, dirname, resolve } from "node:path";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { spawn, exec } from "node:child_process";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { statSync } from "node:fs";
|
|
10
|
+
import { WebSocketServer } from "ws";
|
|
11
|
+
import { readSecrets } from "./secret.js";
|
|
12
|
+
import { validateFormConfigErrors } from "./form.js";
|
|
13
|
+
import { ensureDevEmail, getAccountToken } from "./login.js";
|
|
14
|
+
import { repairScaffolding, ensureSettings, migrateMugJsonSources } from "./sync.js";
|
|
15
|
+
import { parseWorkflowSteps, countSteps } from "../workflow-parser.js";
|
|
16
|
+
import { parseSourceFile } from "../connector-parser.js";
|
|
17
|
+
import { renderForm, renderPortal, renderPortalDetail, renderDevBanner, bindUserParam, paginateQuery, detailQuery, collectSectionQueries, findTableSection, renderWorkspaceHome } from "../surface-renderer.js";
|
|
18
|
+
import net from "node:net";
|
|
19
|
+
let USER_PORT = 8787;
|
|
20
|
+
let WRANGLER_PORT = 8788;
|
|
21
|
+
function isPortFree(port) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const srv = net.createServer();
|
|
24
|
+
srv.once("error", () => resolve(false));
|
|
25
|
+
srv.once("listening", () => { srv.close(); resolve(true); });
|
|
26
|
+
srv.listen(port, "127.0.0.1");
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function findAvailablePorts(startPort) {
|
|
30
|
+
let port = startPort;
|
|
31
|
+
while (port < startPort + 100) {
|
|
32
|
+
const userFree = await isPortFree(port);
|
|
33
|
+
const wranglerFree = await isPortFree(port + 1);
|
|
34
|
+
if (userFree && wranglerFree)
|
|
35
|
+
return { userPort: port, wranglerPort: port + 1 };
|
|
36
|
+
port += 2;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`No available port pair found between ${startPort} and ${startPort + 100}`);
|
|
39
|
+
}
|
|
40
|
+
const wsClients = new Set();
|
|
41
|
+
let wss = null;
|
|
42
|
+
function broadcast(event) {
|
|
43
|
+
const msg = JSON.stringify(event);
|
|
44
|
+
for (const client of wsClients) {
|
|
45
|
+
if (client.readyState === 1)
|
|
46
|
+
client.send(msg);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const debounceTimers = new Map();
|
|
50
|
+
function debouncedBroadcast(area, delayMs = 150) {
|
|
51
|
+
const existing = debounceTimers.get(area);
|
|
52
|
+
if (existing)
|
|
53
|
+
clearTimeout(existing);
|
|
54
|
+
debounceTimers.set(area, setTimeout(() => {
|
|
55
|
+
debounceTimers.delete(area);
|
|
56
|
+
broadcast({ type: "refresh", area });
|
|
57
|
+
}, delayMs));
|
|
58
|
+
}
|
|
59
|
+
function writePidFile(cwd, data) {
|
|
60
|
+
const mugDir = join(cwd, ".mug");
|
|
61
|
+
mkdirSync(mugDir, { recursive: true });
|
|
62
|
+
writeFileSync(join(mugDir, "dev.pid"), JSON.stringify(data, null, 2));
|
|
63
|
+
}
|
|
64
|
+
function cleanPidFile(cwd) {
|
|
65
|
+
const pidPath = join(cwd, ".mug", "dev.pid");
|
|
66
|
+
try {
|
|
67
|
+
unlinkSync(pidPath);
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
}
|
|
71
|
+
export function readPidFile(cwd) {
|
|
72
|
+
const pidPath = join(cwd, ".mug", "dev.pid");
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(pidPath, "utf-8"));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const MUG_FAVICON_SVG = `<svg width="291" height="371" viewBox="0 0 291 371" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
81
|
+
<path d="M134.129 122.279L140.915 122.8L141.155 124.478L141.394 126.157L151.534 126.674L161.674 127.191L167.245 128.392L172.817 129.593L176.743 130.899L180.668 132.206L179.403 132.915L178.139 133.624L170.306 134.807L162.472 135.99L156.284 135.995L150.097 136V138.406V140.813L103.591 140.606L57.0848 140.4L56.8325 138.2L56.5794 136L51.2438 135.984L45.9073 135.968L40.3186 135.187L34.7299 134.406L30.9376 133.523L27.1452 132.641V131.987V131.334L34.1311 129.714L41.117 128.094L47.362 127.247L53.6078 126.4H59.5374H65.4678V124.438V122.476L70.4577 122.192L75.4477 121.908L101.395 121.834L127.343 121.759L134.129 122.279Z" fill="#71B7FB"/>
|
|
82
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.07464 136.078L5.58875 136.957V137.573V138.189L10.5787 139.553L15.5686 140.917L19.6268 142.398L23.6858 143.878L29.8645 144.739L36.0433 145.6H43.9161H51.7891L52.0422 147.8L52.2944 150H103.391H154.488L154.729 147.918L154.97 145.836L167.503 145.398L180.036 144.96L184.827 143.222L189.617 141.485L195.206 139.871L200.795 138.258L201.623 137.208L202.452 136.158L204.418 135.879L206.383 135.6L206.407 231.8L206.431 328H204.315H202.198L201.636 335.2L201.074 342.4H199.138H197.202V344.8V347.2H194.807H192.411V349.6V352H190.016H187.621V354.4V356.8H182.831H178.04V359.2V361.6H173.25H168.46L168.461 363.8L168.461 366H157.084H145.706L145.454 368.2L145.2 370.4H103.391H61.5821L61.329 368.2L61.0767 366L49.3005 365.778L37.5243 365.558V363.578V361.6H33.1331H28.742V359.2V356.8H23.9517H19.1613V354.453V352.106L16.9658 351.853L14.7702 351.6L14.5179 349.4L14.2648 347.2H11.9232H9.58069V342.4V337.6H7.18553H4.79036V332.8V328H2.3952H3.8147e-05V231.6V135.2H0.280282H0.560526L3.07464 136.078ZM19.1613 323.094V243.547V164H23.9517H28.742V239.2V314.4H31.1372H33.5323V321.6V328.8H35.5283H37.5243V330.8V332.8H39.9194H42.3146V335.2V337.6H44.7097H47.1049V342.4V347.2H42.7665H38.4288L38.1757 345L37.9235 342.8L33.4948 342.565L29.0654 342.33L28.1751 339.965L27.2841 337.6H25.649H24.0139L23.7832 330.6L23.5525 323.6L21.3569 323.347L19.1613 323.094Z" fill="#71B7FB"/>
|
|
83
|
+
<path d="M237.972 155.228L239.516 156.056V157.586V159.116L243.708 159.358L247.899 159.6L248.687 161.4L249.475 163.2H251.182H252.889L255.384 163.7L257.879 164.2V166.5V168.8H260.189H262.499L262.961 170.007L263.423 171.214L265.442 172.135L267.46 173.057V175.275V177.494L269.655 177.747L271.851 178L272.101 180.6L272.352 183.2H274.585H276.819L276.438 185.2L276.056 187.2H278.544H281.033V192V196.8H283.428H285.823V204V211.2H288.218H290.613V230V248.8H288.218H285.823V255.547V262.294L283.627 262.547L281.432 262.8L281.192 267.4L280.953 272H278.977H277.001L276.522 274.4L276.043 276.8H274.146H272.25V278.508V280.217L270.454 282.033L268.658 283.85L267.628 285.125L266.598 286.4H264.634H262.67V288.8V291.2H260.274H257.879V293.2V295.2H255.484H253.089V297.6V300H246.303H239.516V302.4V304.8H227.541H215.565V295.622V286.442L227.341 286.222L239.117 286L239.086 283.8L239.054 281.6H243.677H248.299V279.2V276.8H250.694H253.089V274.4V272H255.484H257.879V269.6V267.2H260.274H262.67V264.8V262.4H265.065H267.46V257.492V252.583L269.456 250.73L271.452 248.876V230.038V211.2H269.487H267.522L267.291 204.2L267.061 197.2L264.865 196.947L262.67 196.694V194.347V192H260.274H257.879V190.063V188.127L255.484 187.6L253.089 187.073V185.19V183.306L250.893 183.053L248.698 182.8L248.445 180.6L248.192 178.4H243.907H239.623L239.369 176.2L239.117 174L227.341 173.778L215.565 173.558V163.978V154.4H225.996H236.427L237.972 155.228Z" fill="#71B7FB"/>
|
|
84
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M107.782 6.8V13.6H110.073H112.364L112.858 16.6L113.354 19.6V23.2V26.8L112.866 29.756L112.378 32.7128L110.28 32.956L108.182 33.2L107.929 35.4L107.676 37.6H105.786H103.897L103.644 39.8L103.391 42L101.196 42.2528L99.0001 42.5064V44.0792V45.6512L97.8433 48.4256L96.6864 51.2H95.5527H94.419L93.924 54.2L93.4289 57.2L93.4904 64.4L93.5511 71.6L94.0805 75.6L94.609 79.6L96.8045 79.8528L99.0001 80.1064V82.0528V84H103.391H107.782V79.6V75.2H105.88H103.976L103.495 73.4L103.014 71.6L103.003 65.852L102.992 60.104L103.77 58.052L104.549 56H106.113H107.676L107.929 53.8L108.182 51.6L110.328 51.3512L112.476 51.1032L112.724 48.9512L112.972 46.8L115.167 46.5472L117.363 46.2936V44.4V42.5064L119.559 42.2528L121.754 42L121.992 37.4496L122.23 32.8992L124.387 32.6496L126.544 32.4L126.303 26L126.062 19.6L123.583 12.2392L121.105 4.87841L119.433 4.6392L117.762 4.4L117.51 2.2L117.257 0H112.52H107.782V6.8ZM66.2662 37.548V47.096L65.4878 49.148L64.7093 51.2H63.0926H61.4759V53.6V56H59.0807H56.6856V58.4V60.8H54.2904H51.8952V69.9472V79.0936L54.0908 79.3472L56.2864 79.6L56.5386 81.8L56.7917 84H61.1294H65.4678V79.6V75.2H63.4719H61.4759V70.4V65.6H63.4088H65.3409L65.867 63.2L66.3932 60.8H68.2726H70.152L70.4051 58.6L70.6573 56.4L72.8042 56.1512L74.9519 55.9032L75.1994 53.7512L75.4477 51.6L77.6432 51.3472L79.8388 51.0936V41.9472V32.8H77.4436H75.0485V30.4V28H70.6573H66.2662V37.548ZM146.096 36.6L146.087 45.2L145.592 48.2L145.097 51.2H143.206H141.315V53.6V56H138.919H136.524V58.4V60.8H134.129H131.734V69.9368V79.0728L134.129 79.6L136.524 80.1272V82.0632V84H140.915H145.307V79.6V75.2H143.359H141.413L140.876 71.6096L140.338 68.02L140.801 66.8096L141.264 65.6H143.286H145.307V63.2V60.8H147.649H149.991L150.244 58.6L150.496 56.4L152.692 56.1472L154.887 55.8936V53.5472V51.2H156.883H158.879V42V32.8H156.883H154.887V30.4V28H150.496H146.105L146.096 36.6Z" fill="#71B7FB"/>
|
|
85
|
+
<path d="M145.307 105.2V107.2L147.901 107.222L150.496 107.243L161.274 107.678L172.053 108.113L173.051 108.431L174.049 108.75V110.261V111.772L178.719 112.262L183.39 112.75L189.697 114.747L196.004 116.744L199.797 119.154L203.589 121.565V123.164V124.763L201.78 125.951L199.97 127.139L196.544 128.35L193.118 129.562L190.538 126.981L187.958 124.4L182.8 122.609L177.641 120.818L167.262 118.471L156.883 116.125L149.698 115.261L142.512 114.396L132.217 113.598L121.922 112.8H104.641H87.362L73.0214 113.988L58.6815 115.176L53.0928 116.069L47.5041 116.962L35.7965 119.63L24.0882 122.298L21.0264 123.871L17.9638 125.444L16.0987 127.446L14.2345 129.449L12.0844 128.831L9.93437 128.214L6.51408 126.465L3.09379 124.716L3.3429 122.962L3.59278 121.209L7.73164 118.734L11.8705 116.26L17.2644 114.486L22.6575 112.711L27.637 112.246L32.6158 111.78L33.0892 109.89L33.5627 108H40.2108H46.8598L54.1675 107.48L61.4759 106.961V105.08V103.2H103.391H145.307V105.2Z" fill="#71B7FB"/>
|
|
86
|
+
</svg>`;
|
|
87
|
+
const surfaceErrors = new Map();
|
|
88
|
+
let lastWranglerError = null;
|
|
89
|
+
function renderErrorOverlay(title, message, file, hint) {
|
|
90
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title>
|
|
91
|
+
<style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:system-ui,sans-serif;background:#1a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}
|
|
92
|
+
.overlay{max-width:700px;width:100%;background:#1f1010;border:2px solid #c0392b;border-radius:12px;padding:32px;box-shadow:0 0 40px rgba(192,57,43,0.2)}
|
|
93
|
+
h1{color:#e74c3c;font-size:18px;margin-bottom:16px;display:flex;align-items:center;gap:8px}
|
|
94
|
+
h1::before{content:"✕";display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#c0392b;color:white;border-radius:50%;font-size:13px;font-weight:700}
|
|
95
|
+
pre{background:#0d0505;padding:16px;border-radius:8px;overflow-x:auto;font-size:13px;line-height:1.6;color:#ff6b6b;white-space:pre-wrap;margin-bottom:16px;border:1px solid #2a1515}
|
|
96
|
+
.file{color:#888;font-size:13px;margin-bottom:12px;font-family:monospace}.hint{color:#999;font-size:13px;margin-top:8px}
|
|
97
|
+
</style></head><body><div class="overlay">
|
|
98
|
+
<h1>${title}</h1>
|
|
99
|
+
${file ? `<div class="file">${file}</div>` : ""}
|
|
100
|
+
<pre>${message.replace(/</g, "<").replace(/>/g, ">")}</pre>
|
|
101
|
+
${hint ? `<p class="hint">${hint}</p>` : ""}
|
|
102
|
+
</div>
|
|
103
|
+
${LIVE_RELOAD_SCRIPT}
|
|
104
|
+
</body></html>`;
|
|
105
|
+
}
|
|
106
|
+
function parseBreadcrumb(url) {
|
|
107
|
+
const from = url.searchParams.get("from");
|
|
108
|
+
const fromLabel = url.searchParams.get("fromLabel");
|
|
109
|
+
if (!from)
|
|
110
|
+
return undefined;
|
|
111
|
+
return { href: from, label: fromLabel || "Back" };
|
|
112
|
+
}
|
|
113
|
+
function loadSurfaceConfigs(cwd) {
|
|
114
|
+
const surfacesDir = join(cwd, "src", "surfaces");
|
|
115
|
+
const configs = new Map();
|
|
116
|
+
if (!existsSync(surfacesDir))
|
|
117
|
+
return configs;
|
|
118
|
+
surfaceErrors.clear();
|
|
119
|
+
for (const file of readdirSync(surfacesDir)) {
|
|
120
|
+
if (!file.endsWith(".json"))
|
|
121
|
+
continue;
|
|
122
|
+
const id = file === "_home.json" ? "_home" : file.replace(/\.json$/, "");
|
|
123
|
+
if (file === "_home.json") {
|
|
124
|
+
try {
|
|
125
|
+
const raw = readFileSync(join(surfacesDir, file), "utf-8");
|
|
126
|
+
configs.set("_home", JSON.parse(raw));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
130
|
+
console.error(`[surfaces] Failed to load ${file}: ${msg}`);
|
|
131
|
+
surfaceErrors.set(id, msg);
|
|
132
|
+
broadcast({ type: "error", message: `Surface ${file}: ${msg}` });
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const raw = readFileSync(join(surfacesDir, file), "utf-8");
|
|
138
|
+
const config = JSON.parse(raw);
|
|
139
|
+
config.surfaceId = config.surfaceId ?? id;
|
|
140
|
+
if (config.type !== "portal") {
|
|
141
|
+
const errs = validateFormConfigErrors(`src/surfaces/${file}`, config);
|
|
142
|
+
if (errs.length > 0) {
|
|
143
|
+
const msg = errs.map(e => e.message).join("; ");
|
|
144
|
+
console.error(`[surfaces] ${file}: ${msg}`);
|
|
145
|
+
surfaceErrors.set(id, msg);
|
|
146
|
+
broadcast({ type: "error", message: `Surface ${file}: ${msg}` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (config.branding) {
|
|
150
|
+
const b = config.branding;
|
|
151
|
+
if (b.logo && !b.logo.startsWith("/") && !b.logo.startsWith("http"))
|
|
152
|
+
b.logo = `/_branding/${encodeURIComponent(resolve(cwd, b.logo))}`;
|
|
153
|
+
if (b.logoSquare && !b.logoSquare.startsWith("/") && !b.logoSquare.startsWith("http"))
|
|
154
|
+
b.logoSquare = `/_branding/${encodeURIComponent(resolve(cwd, b.logoSquare))}`;
|
|
155
|
+
}
|
|
156
|
+
configs.set(id, config);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
160
|
+
console.error(`[surfaces] Failed to load ${file}: ${msg}`);
|
|
161
|
+
surfaceErrors.set(id, msg);
|
|
162
|
+
broadcast({ type: "error", message: `Surface ${file}: ${msg}` });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return configs;
|
|
166
|
+
}
|
|
167
|
+
async function proxyToWrangler(req, res, path) {
|
|
168
|
+
try {
|
|
169
|
+
const body = await readBody(req);
|
|
170
|
+
const headers = {};
|
|
171
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
172
|
+
if (value && typeof value === "string")
|
|
173
|
+
headers[key] = value;
|
|
174
|
+
else if (Array.isArray(value))
|
|
175
|
+
headers[key] = value.join(", ");
|
|
176
|
+
}
|
|
177
|
+
headers.host = `localhost:${WRANGLER_PORT}`;
|
|
178
|
+
const upstream = await fetch(`http://localhost:${WRANGLER_PORT}${path}`, {
|
|
179
|
+
method: req.method ?? "GET",
|
|
180
|
+
headers,
|
|
181
|
+
body: req.method !== "GET" && req.method !== "HEAD" ? new Uint8Array(body) : undefined,
|
|
182
|
+
});
|
|
183
|
+
res.writeHead(upstream.status, Object.fromEntries(upstream.headers.entries()));
|
|
184
|
+
const arrayBuf = await upstream.arrayBuffer();
|
|
185
|
+
res.end(Buffer.from(arrayBuf));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
const accept = req.headers.accept ?? "";
|
|
189
|
+
if (accept.includes("text/html") && lastWranglerError) {
|
|
190
|
+
res.writeHead(502, { "Content-Type": "text/html; charset=utf-8" });
|
|
191
|
+
res.end(renderErrorOverlay("Build Error", lastWranglerError, "wrangler dev", "Fix the error and save — the page will reload automatically."));
|
|
192
|
+
}
|
|
193
|
+
else if (accept.includes("text/html")) {
|
|
194
|
+
res.writeHead(502, { "Content-Type": "text/html; charset=utf-8" });
|
|
195
|
+
res.end(renderErrorOverlay("Worker Unavailable", "Wrangler dev server is not reachable.\nIt may still be starting, or it crashed.", undefined, "Check the terminal for wrangler output."));
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
199
|
+
res.end(JSON.stringify({ error: "Wrangler dev server not reachable", detail: lastWranglerError }));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function readBody(req) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const chunks = [];
|
|
206
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
207
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
208
|
+
req.on("error", reject);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function parseCookies(req) {
|
|
212
|
+
const cookies = {};
|
|
213
|
+
const header = req.headers.cookie;
|
|
214
|
+
if (!header)
|
|
215
|
+
return cookies;
|
|
216
|
+
for (const pair of header.split(";")) {
|
|
217
|
+
const [name, ...rest] = pair.trim().split("=");
|
|
218
|
+
if (name)
|
|
219
|
+
cookies[name] = decodeURIComponent(rest.join("="));
|
|
220
|
+
}
|
|
221
|
+
return cookies;
|
|
222
|
+
}
|
|
223
|
+
function getDevIdentity(req) {
|
|
224
|
+
const cookies = parseCookies(req);
|
|
225
|
+
return cookies.mug_dev_identity ?? "";
|
|
226
|
+
}
|
|
227
|
+
function makeDevSession(identity) {
|
|
228
|
+
if (!identity)
|
|
229
|
+
return null;
|
|
230
|
+
return { email: identity, verifiedAt: new Date().toISOString() };
|
|
231
|
+
}
|
|
232
|
+
function queryLocal(database, sql, params) {
|
|
233
|
+
const cwd = process.cwd();
|
|
234
|
+
const dbPath = join(cwd, "databases", `${database}.db`);
|
|
235
|
+
if (!existsSync(dbPath)) {
|
|
236
|
+
return { rows: [] };
|
|
237
|
+
}
|
|
238
|
+
const db = new Database(dbPath, { readonly: true });
|
|
239
|
+
try {
|
|
240
|
+
const stmt = db.prepare(sql);
|
|
241
|
+
const rows = (params.length > 0 ? stmt.all(...params) : stmt.all());
|
|
242
|
+
return { rows };
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
db.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function queryWrangler(database, sql, params) {
|
|
249
|
+
const res = await fetch(`http://localhost:${WRANGLER_PORT}/api/query`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify({ database, sql, params }),
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok)
|
|
255
|
+
throw new Error(`Query failed: ${res.status} ${await res.text()}`);
|
|
256
|
+
return res.json();
|
|
257
|
+
}
|
|
258
|
+
function getKnownDatabases(cwd) {
|
|
259
|
+
const dbDir = join(cwd, "databases");
|
|
260
|
+
if (!existsSync(dbDir))
|
|
261
|
+
return [];
|
|
262
|
+
return readdirSync(dbDir).filter((f) => f.endsWith(".db")).map((f) => f.replace(/\.db$/, ""));
|
|
263
|
+
}
|
|
264
|
+
async function seedDatabasesToDOs(cwd) {
|
|
265
|
+
const dbNames = getKnownDatabases(cwd);
|
|
266
|
+
if (dbNames.length === 0)
|
|
267
|
+
return;
|
|
268
|
+
console.log(`[db] Seeding ${dbNames.length} database${dbNames.length !== 1 ? "s" : ""} into dev DOs...`);
|
|
269
|
+
for (const dbName of dbNames) {
|
|
270
|
+
const dbPath = join(cwd, "databases", `${dbName}.db`);
|
|
271
|
+
const db = new Database(dbPath, { readonly: true });
|
|
272
|
+
try {
|
|
273
|
+
const tableRows = db.prepare("SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
274
|
+
if (tableRows.length === 0)
|
|
275
|
+
continue;
|
|
276
|
+
const tables = {};
|
|
277
|
+
for (const { name, sql: ddl } of tableRows) {
|
|
278
|
+
const rows = db.prepare(`SELECT * FROM "${name}"`).all();
|
|
279
|
+
tables[name] = { ddl, rows };
|
|
280
|
+
}
|
|
281
|
+
const res = await fetch(`http://localhost:${WRANGLER_PORT}/api/seed`, {
|
|
282
|
+
method: "POST",
|
|
283
|
+
headers: { "Content-Type": "application/json" },
|
|
284
|
+
body: JSON.stringify({ database: dbName, tables }),
|
|
285
|
+
signal: AbortSignal.timeout(10000),
|
|
286
|
+
});
|
|
287
|
+
if (res.ok) {
|
|
288
|
+
const result = (await res.json());
|
|
289
|
+
console.log(`[db] ${dbName}: ${result.tables_created} tables, ${result.rows_inserted} rows`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
console.warn(`[db] ${dbName}: seed failed — ${err instanceof Error ? err.message : err}`);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
db.close();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async function writebackDOsToFiles(cwd) {
|
|
301
|
+
const dbNames = getKnownDatabases(cwd);
|
|
302
|
+
if (dbNames.length === 0)
|
|
303
|
+
return;
|
|
304
|
+
const dbDir = join(cwd, "databases");
|
|
305
|
+
mkdirSync(dbDir, { recursive: true });
|
|
306
|
+
console.log(`[db] Writing back ${dbNames.length} database${dbNames.length !== 1 ? "s" : ""} from DOs...`);
|
|
307
|
+
for (const dbName of dbNames) {
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch(`http://localhost:${WRANGLER_PORT}/api/export`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "Content-Type": "application/json" },
|
|
312
|
+
body: JSON.stringify({ database: dbName }),
|
|
313
|
+
signal: AbortSignal.timeout(10000),
|
|
314
|
+
});
|
|
315
|
+
if (!res.ok)
|
|
316
|
+
continue;
|
|
317
|
+
const data = (await res.json());
|
|
318
|
+
if (Object.keys(data.tables).length === 0)
|
|
319
|
+
continue;
|
|
320
|
+
const dbPath = join(dbDir, `${dbName}.db`);
|
|
321
|
+
if (existsSync(dbPath)) {
|
|
322
|
+
const backup = new Database(dbPath);
|
|
323
|
+
backup.close();
|
|
324
|
+
}
|
|
325
|
+
const db = new Database(dbPath);
|
|
326
|
+
try {
|
|
327
|
+
db.pragma("journal_mode = WAL");
|
|
328
|
+
const existing = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
329
|
+
for (const { name } of existing) {
|
|
330
|
+
db.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
331
|
+
}
|
|
332
|
+
for (const [, tableData] of Object.entries(data.tables)) {
|
|
333
|
+
db.exec(tableData.ddl);
|
|
334
|
+
if (tableData.rows.length === 0)
|
|
335
|
+
continue;
|
|
336
|
+
const columns = Object.keys(tableData.rows[0]);
|
|
337
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
338
|
+
const tableName = tableData.ddl.match(/CREATE TABLE\s+"?([^"\s(]+)"?/i)?.[1] ?? "";
|
|
339
|
+
const insert = db.prepare(`INSERT INTO "${tableName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})`);
|
|
340
|
+
const insertMany = db.transaction((rows) => {
|
|
341
|
+
for (const row of rows) {
|
|
342
|
+
insert.run(...columns.map((c) => {
|
|
343
|
+
const v = row[c];
|
|
344
|
+
if (v === null || v === undefined)
|
|
345
|
+
return null;
|
|
346
|
+
if (typeof v === "object")
|
|
347
|
+
return JSON.stringify(v);
|
|
348
|
+
return v;
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
insertMany(tableData.rows);
|
|
353
|
+
}
|
|
354
|
+
console.log(`[db] ${dbName}: written back`);
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
db.close();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
console.warn(`[db] ${dbName}: writeback failed — ${err instanceof Error ? err.message : err}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async function triggerWorkflow(workflow, params) {
|
|
366
|
+
await fetch(`http://localhost:${WRANGLER_PORT}/run/${workflow}`, {
|
|
367
|
+
method: "POST",
|
|
368
|
+
headers: { "Content-Type": "application/json" },
|
|
369
|
+
body: JSON.stringify(params),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
async function getAuthUsers(config) {
|
|
373
|
+
const { access } = config;
|
|
374
|
+
if (access.mode !== "auth" || !access.table || !access.matchColumn)
|
|
375
|
+
return undefined;
|
|
376
|
+
const database = config.database ?? config.workspace;
|
|
377
|
+
try {
|
|
378
|
+
const result = await queryLocal(database, `SELECT DISTINCT ${access.matchColumn} AS val FROM ${access.table} ORDER BY val`, []);
|
|
379
|
+
return result.rows.map(r => String(r.val)).filter(Boolean);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function getFormAuthUsers(config, workspaceName) {
|
|
386
|
+
const access = config.access;
|
|
387
|
+
if (!access || access.mode !== "auth" || !access.table || !access.matchColumn)
|
|
388
|
+
return undefined;
|
|
389
|
+
const database = config.database ?? workspaceName;
|
|
390
|
+
try {
|
|
391
|
+
const result = await queryLocal(database, `SELECT DISTINCT ${access.matchColumn} AS val FROM ${access.table} ORDER BY val`, []);
|
|
392
|
+
return result.rows.map(r => String(r.val)).filter(Boolean);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function resolveDevPrefill(config, fields, session, workspaceName, urlParams) {
|
|
399
|
+
const values = {};
|
|
400
|
+
const access = config.access;
|
|
401
|
+
const database = config.database ?? workspaceName;
|
|
402
|
+
if (session && access?.mode === "auth" && access.table && access.matchColumn) {
|
|
403
|
+
const identifier = session.email ?? session.phone;
|
|
404
|
+
if (identifier) {
|
|
405
|
+
try {
|
|
406
|
+
let sql;
|
|
407
|
+
let params;
|
|
408
|
+
if (access.query) {
|
|
409
|
+
const q = access.query;
|
|
410
|
+
const paramCount = (q.match(/:identity/g) || []).length;
|
|
411
|
+
sql = q.replace(/:identity/g, "?");
|
|
412
|
+
params = Array(paramCount).fill(identifier);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
sql = `SELECT * FROM ${access.table} WHERE ${access.matchColumn} = ? LIMIT 1`;
|
|
416
|
+
params = [identifier];
|
|
417
|
+
}
|
|
418
|
+
const result = await queryLocal(database, sql, params);
|
|
419
|
+
if (result.rows?.[0])
|
|
420
|
+
session.authRow = result.rows[0];
|
|
421
|
+
}
|
|
422
|
+
catch { }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
for (const field of fields) {
|
|
426
|
+
if (!field.prefill)
|
|
427
|
+
continue;
|
|
428
|
+
const pf = field.prefill;
|
|
429
|
+
if (pf.source === "auth" && pf.column && session?.authRow) {
|
|
430
|
+
values[field.name] = session.authRow[pf.column];
|
|
431
|
+
}
|
|
432
|
+
else if (pf.source === "url" && pf.param) {
|
|
433
|
+
const val = urlParams.get(pf.param);
|
|
434
|
+
if (val != null)
|
|
435
|
+
values[field.name] = val;
|
|
436
|
+
}
|
|
437
|
+
else if (pf.source === "db" && pf.table && pf.column && pf.match) {
|
|
438
|
+
const matchVal = pf.match.param ? urlParams.get(pf.match.param)
|
|
439
|
+
: pf.match.field ? values[pf.match.field]
|
|
440
|
+
: undefined;
|
|
441
|
+
if (matchVal != null) {
|
|
442
|
+
try {
|
|
443
|
+
const result = await queryLocal(database, `SELECT ${pf.column} FROM ${pf.table} WHERE ${pf.match.column} = ? LIMIT 1`, [String(matchVal)]);
|
|
444
|
+
if (result.rows?.[0])
|
|
445
|
+
values[field.name] = result.rows[0][pf.column];
|
|
446
|
+
}
|
|
447
|
+
catch { }
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return values;
|
|
452
|
+
}
|
|
453
|
+
async function handleFormSubmit(req, res, surfaceConfig, surfaceId, workspaceName) {
|
|
454
|
+
try {
|
|
455
|
+
const body = await readBody(req);
|
|
456
|
+
const formData = JSON.parse(body.toString("utf-8"));
|
|
457
|
+
const workflow = surfaceConfig.workflow;
|
|
458
|
+
if (!workflow) {
|
|
459
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
460
|
+
res.end(JSON.stringify({ error: "No workflow configured for this surface" }));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const identity = getDevIdentity(req);
|
|
464
|
+
const session = makeDevSession(identity);
|
|
465
|
+
const params = {
|
|
466
|
+
...formData,
|
|
467
|
+
_surface: surfaceId,
|
|
468
|
+
_workspace: workspaceName,
|
|
469
|
+
};
|
|
470
|
+
if (session) {
|
|
471
|
+
params._verified_email = session.email;
|
|
472
|
+
params._verified_phone = session.phone;
|
|
473
|
+
}
|
|
474
|
+
await triggerWorkflow(workflow, params);
|
|
475
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
476
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
480
|
+
console.error(`\n❌ SUBMIT ERROR: /${surfaceId}/submit\n ${msg}\n`);
|
|
481
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
482
|
+
res.end(JSON.stringify({ error: msg }));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const LIVE_RELOAD_SCRIPT = `<script>(function(){var ws,t;function c(){ws=new WebSocket("ws://"+location.host+"/_dev/ws");ws.onmessage=function(e){try{var d=JSON.parse(e.data);if(d.type==="refresh"||d.type==="reload")location.reload()}catch(x){}};ws.onclose=function(){ws=null;t=setTimeout(c,1000)};ws.onerror=function(){ws&&ws.close()}}c()})()</script>`;
|
|
486
|
+
function injectBanner(html, banner) {
|
|
487
|
+
const withBanner = html.replace("<body>", `<body>\n${banner}`);
|
|
488
|
+
if (withBanner.includes("</body>")) {
|
|
489
|
+
return withBanner.replace("</body>", `${LIVE_RELOAD_SCRIPT}\n</body>`);
|
|
490
|
+
}
|
|
491
|
+
return withBanner + LIVE_RELOAD_SCRIPT;
|
|
492
|
+
}
|
|
493
|
+
async function handlePortalList(config, req, res, basePath, wsBranding) {
|
|
494
|
+
const identity = getDevIdentity(req);
|
|
495
|
+
const session = makeDevSession(identity);
|
|
496
|
+
const authUsers = await getAuthUsers(config);
|
|
497
|
+
const mergedBranding = config.branding || wsBranding ? { ...wsBranding, ...config.branding } : undefined;
|
|
498
|
+
const banner = renderDevBanner(identity, authUsers, mergedBranding?.accentColor ?? wsBranding?.accentColor, config.workspace, `${config.title ?? config.surfaceId} Portal`);
|
|
499
|
+
const url = new URL(req.url ?? "/", `http://localhost:${USER_PORT}`);
|
|
500
|
+
const activeTabId = url.searchParams.get("tab") ?? null;
|
|
501
|
+
const breadcrumb = parseBreadcrumb(url);
|
|
502
|
+
const database = config.database ?? config.workspace;
|
|
503
|
+
const brandedConfig = mergedBranding ? { ...config, branding: mergedBranding } : config;
|
|
504
|
+
const activeTab = config.tabs.find(t => t.id === activeTabId) ?? config.tabs[0];
|
|
505
|
+
if (!activeTab) {
|
|
506
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
507
|
+
res.end(injectBanner(renderPortal(brandedConfig, new Map(), new Map(), {}, null, session, basePath, breadcrumb), banner));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const sectionQueries = [
|
|
511
|
+
...collectSectionQueries(config.sections ?? [], "_top"),
|
|
512
|
+
...collectSectionQueries(activeTab.sections, activeTab.id),
|
|
513
|
+
];
|
|
514
|
+
const queryResults = new Map();
|
|
515
|
+
const tableCounts = new Map();
|
|
516
|
+
const pages = {};
|
|
517
|
+
for (const [key, val] of url.searchParams.entries()) {
|
|
518
|
+
if (key.startsWith("page_"))
|
|
519
|
+
pages[key] = Math.max(1, parseInt(val) || 1);
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const queryPromises = sectionQueries.map(async ({ key, query }) => {
|
|
523
|
+
const bound = bindUserParam(query, session);
|
|
524
|
+
const sectionParts = key.split(":");
|
|
525
|
+
const sectionIndex = parseInt(sectionParts[sectionParts.length - 1]);
|
|
526
|
+
const section = activeTab.sections[sectionIndex];
|
|
527
|
+
const isTable = section?.type === "table";
|
|
528
|
+
if (isTable) {
|
|
529
|
+
const tableSection = section;
|
|
530
|
+
const pageSize = tableSection.pageSize ?? 25;
|
|
531
|
+
const pageKey = `page_${sectionIndex}`;
|
|
532
|
+
const page = pages[pageKey] ?? 1;
|
|
533
|
+
const paginated = paginateQuery(bound.sql, bound.userParams, page, pageSize);
|
|
534
|
+
const [countResult, dataResult] = await Promise.all([
|
|
535
|
+
queryLocal(database, paginated.countSql, paginated.countParams),
|
|
536
|
+
queryLocal(database, paginated.dataSql, paginated.dataParams),
|
|
537
|
+
]);
|
|
538
|
+
queryResults.set(key, dataResult.rows);
|
|
539
|
+
tableCounts.set(key, countResult.rows[0]?.total ?? 0);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
const result = await queryLocal(database, bound.sql, bound.userParams);
|
|
543
|
+
queryResults.set(key, result.rows);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
const tabCountQueries = config.tabs
|
|
547
|
+
.filter(t => t.countQuery)
|
|
548
|
+
.map(async (t) => {
|
|
549
|
+
const bound = bindUserParam(t.countQuery, session);
|
|
550
|
+
const result = await queryLocal(database, bound.sql, bound.userParams);
|
|
551
|
+
const row = result.rows[0];
|
|
552
|
+
const val = row ? Number(Object.values(row)[0] ?? 0) : 0;
|
|
553
|
+
return [t.id, val];
|
|
554
|
+
});
|
|
555
|
+
const [, ...tabCountResults] = await Promise.all([Promise.all(queryPromises), ...tabCountQueries]);
|
|
556
|
+
const tabCounts = new Map(tabCountResults);
|
|
557
|
+
const html = renderPortal(brandedConfig, queryResults, tableCounts, pages, activeTabId, session, basePath, breadcrumb, tabCounts);
|
|
558
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
559
|
+
res.end(injectBanner(html, banner));
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
563
|
+
res.end(`<html><body>${banner}<div style="max-width:600px;margin:40px auto;font-family:sans-serif"><h1>Query Error</h1><pre style="background:#fee;padding:16px;border-radius:8px;overflow:auto">${String(err)}</pre><p style="color:#666">Check that the database table exists. You may need to submit a form first to create it.</p></div></body></html>`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async function handlePortalDetail(config, req, res, basePath, rowId, wsBranding) {
|
|
567
|
+
const identity = getDevIdentity(req);
|
|
568
|
+
const session = makeDevSession(identity);
|
|
569
|
+
const authUsers = await getAuthUsers(config);
|
|
570
|
+
const detailBranding = config.branding || wsBranding ? { ...wsBranding, ...config.branding } : undefined;
|
|
571
|
+
const banner = renderDevBanner(identity, authUsers, detailBranding?.accentColor ?? wsBranding?.accentColor, config.workspace, `${config.title ?? config.surfaceId} Portal`);
|
|
572
|
+
const database = config.database ?? config.workspace;
|
|
573
|
+
const url = new URL(req.url ?? "/", `http://localhost:${USER_PORT}`);
|
|
574
|
+
const tabId = url.searchParams.get("tab") ?? config.tabs[0]?.id ?? "";
|
|
575
|
+
const sectionIndex = parseInt(url.searchParams.get("section") ?? "0");
|
|
576
|
+
const found = findTableSection(config, tabId, sectionIndex);
|
|
577
|
+
if (!found) {
|
|
578
|
+
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
579
|
+
res.end(`<html><body>${banner}<div style="max-width:600px;margin:40px auto;font-family:sans-serif"><h1>Not Found</h1><p>Table section not found.</p><a href="${basePath}">Back</a></div></body></html>`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const { section: tableSection } = found;
|
|
583
|
+
const primaryKey = tableSection.primaryKey ?? "id";
|
|
584
|
+
const bound = bindUserParam(tableSection.query, session);
|
|
585
|
+
const detail = detailQuery(bound.sql, bound.userParams, primaryKey, rowId);
|
|
586
|
+
try {
|
|
587
|
+
const result = await queryLocal(database, detail.sql, detail.params);
|
|
588
|
+
if (result.rows.length === 0) {
|
|
589
|
+
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
590
|
+
res.end(`<html><body>${banner}<div style="max-width:600px;margin:40px auto;font-family:sans-serif"><h1>Not Found</h1><p>Record not found.</p><a href="${basePath}?tab=${tabId}">Back</a></div></body></html>`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const mergedBranding = config.branding || wsBranding ? { ...wsBranding, ...config.branding } : undefined;
|
|
594
|
+
const brandedConfig = mergedBranding ? { ...config, branding: mergedBranding } : config;
|
|
595
|
+
const html = renderPortalDetail(brandedConfig, tableSection, result.rows[0], session, tabId, basePath);
|
|
596
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
597
|
+
res.end(injectBanner(html, banner));
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
601
|
+
res.end(`<html><body>${banner}<div style="max-width:600px;margin:40px auto;font-family:sans-serif"><h1>Query Error</h1><pre style="background:#fee;padding:16px;border-radius:8px;overflow:auto">${String(err)}</pre></div></body></html>`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function handlePortalAction(config, req, res) {
|
|
605
|
+
const identity = getDevIdentity(req);
|
|
606
|
+
const reqUrl = new URL(req.url ?? "/", `http://localhost:${USER_PORT}`);
|
|
607
|
+
const body = await readBody(req);
|
|
608
|
+
const data = JSON.parse(body.toString());
|
|
609
|
+
const tabId = data.tab ?? reqUrl.searchParams.get("tab") ?? config.tabs[0]?.id ?? "";
|
|
610
|
+
const sectionIndex = data.sectionIndex ?? Number(reqUrl.searchParams.get("section") ?? "0");
|
|
611
|
+
const found = findTableSection(config, tabId, sectionIndex);
|
|
612
|
+
if (!found) {
|
|
613
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
614
|
+
res.end(JSON.stringify({ error: "Table section not found" }));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const action = (found.section.actions ?? []).find(a => a.name === data.action);
|
|
618
|
+
if (!action) {
|
|
619
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
620
|
+
res.end(JSON.stringify({ error: `Unknown action: ${data.action}` }));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (!action.workflow) {
|
|
624
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
625
|
+
res.end(JSON.stringify({ status: "ok", timeline: true }));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
await triggerWorkflow(action.workflow, {
|
|
630
|
+
action: data.action,
|
|
631
|
+
...data.rowData,
|
|
632
|
+
_verified_email: identity,
|
|
633
|
+
_surface: config.surfaceId,
|
|
634
|
+
_workspace: config.workspace,
|
|
635
|
+
});
|
|
636
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
637
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
641
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async function handleNotifySend(req, res, devOverrides = {}) {
|
|
645
|
+
const raw = await readBody(req);
|
|
646
|
+
const body = JSON.parse(raw.toString());
|
|
647
|
+
const override = devOverrides[body.channel];
|
|
648
|
+
if (!override) {
|
|
649
|
+
const hint = `Add "dev": { "${body.channel}": "<your-${body.channel}>" } to mug.json`;
|
|
650
|
+
console.log(`\n[${body.channel}] BLOCKED — no dev.${body.channel} in mug.json`);
|
|
651
|
+
console.log(` Would have sent to: ${body.to}`);
|
|
652
|
+
console.log(` Fix: ${hint}\n`);
|
|
653
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
654
|
+
res.end(JSON.stringify({ status: "blocked", channel: body.channel, error: `No dev.${body.channel} in mug.json — ${hint}` }));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const originalTo = body.to;
|
|
658
|
+
const recipient = override;
|
|
659
|
+
console.log(`[${body.channel}] Redirecting: ${originalTo} → ${recipient}`);
|
|
660
|
+
const subject = body.channel === "email"
|
|
661
|
+
? `[DEV → ${originalTo}] ${body.subject ?? "Notification from Mug"}`
|
|
662
|
+
: body.subject;
|
|
663
|
+
try {
|
|
664
|
+
const token = getAccountToken();
|
|
665
|
+
const result = await fetch("https://api.mug.work/dev/notify", {
|
|
666
|
+
method: "POST",
|
|
667
|
+
headers: {
|
|
668
|
+
"Content-Type": "application/json",
|
|
669
|
+
Authorization: `Bearer ${token}`,
|
|
670
|
+
},
|
|
671
|
+
body: JSON.stringify({
|
|
672
|
+
workspace: body.workspace,
|
|
673
|
+
channel: body.channel,
|
|
674
|
+
to: recipient,
|
|
675
|
+
message: body.message,
|
|
676
|
+
subject,
|
|
677
|
+
fromName: body.fromName,
|
|
678
|
+
cta: body.cta,
|
|
679
|
+
branding: body.branding,
|
|
680
|
+
...(body.channel === "slack" ? {
|
|
681
|
+
blocks: body.blocks,
|
|
682
|
+
thread_ts: body.thread_ts,
|
|
683
|
+
unfurl_links: body.unfurl_links,
|
|
684
|
+
unfurl_media: body.unfurl_media,
|
|
685
|
+
slackBotToken: body.slackBotToken,
|
|
686
|
+
} : {}),
|
|
687
|
+
}),
|
|
688
|
+
});
|
|
689
|
+
if (!result.ok) {
|
|
690
|
+
const errText = await result.text().catch(() => result.statusText);
|
|
691
|
+
console.log(`[${body.channel}] delivery_failed: platform API returned ${result.status} — ${errText}`);
|
|
692
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
693
|
+
res.end(JSON.stringify({ status: "delivery_failed", error: `Platform API ${result.status}: ${errText}` }));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const data = await result.json();
|
|
697
|
+
if (data.error) {
|
|
698
|
+
console.log(`[${body.channel}] ${data.status}: ${data.error}`);
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
console.log(`[${body.channel}] ${data.status}`);
|
|
702
|
+
}
|
|
703
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
704
|
+
res.end(JSON.stringify(data));
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
console.log(`[${body.channel}] delivery error: ${err}`);
|
|
708
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
709
|
+
res.end(JSON.stringify({ status: "delivery_failed", error: String(err) }));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async function handleAiComplete(req, res) {
|
|
713
|
+
const raw = await readBody(req);
|
|
714
|
+
const bodyStr = raw.toString();
|
|
715
|
+
const body = JSON.parse(bodyStr);
|
|
716
|
+
const promptPreview = (body.prompt ?? "").slice(0, 80).replace(/\n/g, " ");
|
|
717
|
+
console.log(`[ai] ${body.provider ?? "auto"}/${body.model ?? "auto"} (billing: ${body.billing === "mug-metered" ? "metered" : "byok"})`);
|
|
718
|
+
console.log(`[ai] prompt: "${promptPreview}${(body.prompt ?? "").length > 80 ? "..." : ""}"`);
|
|
719
|
+
if (body.system)
|
|
720
|
+
console.log(`[ai] system: "${body.system.slice(0, 80)}${body.system.length > 80 ? "..." : ""}"`);
|
|
721
|
+
try {
|
|
722
|
+
const token = getAccountToken();
|
|
723
|
+
const result = await fetch("https://api.mug.work/dev/ai", {
|
|
724
|
+
method: "POST",
|
|
725
|
+
headers: {
|
|
726
|
+
"Content-Type": "application/json",
|
|
727
|
+
Authorization: `Bearer ${token}`,
|
|
728
|
+
},
|
|
729
|
+
body: bodyStr,
|
|
730
|
+
});
|
|
731
|
+
if (!result.ok) {
|
|
732
|
+
const errText = await result.text().catch(() => result.statusText);
|
|
733
|
+
console.log(`[ai] failed: platform API returned ${result.status} — ${errText}`);
|
|
734
|
+
res.writeHead(result.status, { "Content-Type": "application/json" });
|
|
735
|
+
res.end(JSON.stringify({ error: `AI request failed (${result.status}): ${errText}` }));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const data = await result.json();
|
|
739
|
+
const text = data.text ?? "";
|
|
740
|
+
console.log(`[ai] response: "${text.slice(0, 120)}${text.length > 120 ? "..." : ""}" (${data.usage?.input_tokens ?? "?"}in/${data.usage?.output_tokens ?? "?"}out)`);
|
|
741
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
742
|
+
res.end(JSON.stringify(data));
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
console.log(`[ai] error: ${err}`);
|
|
746
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
747
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async function handleAiEmbed(req, res) {
|
|
751
|
+
const raw = await readBody(req);
|
|
752
|
+
const bodyStr = raw.toString();
|
|
753
|
+
const body = JSON.parse(bodyStr);
|
|
754
|
+
const count = body.texts?.length ?? 0;
|
|
755
|
+
console.log(`[ai] embed ${count} text(s)`);
|
|
756
|
+
try {
|
|
757
|
+
const token = getAccountToken();
|
|
758
|
+
const result = await fetch("https://api.mug.work/dev/embed", {
|
|
759
|
+
method: "POST",
|
|
760
|
+
headers: {
|
|
761
|
+
"Content-Type": "application/json",
|
|
762
|
+
Authorization: `Bearer ${token}`,
|
|
763
|
+
},
|
|
764
|
+
body: bodyStr,
|
|
765
|
+
});
|
|
766
|
+
if (!result.ok) {
|
|
767
|
+
const errText = await result.text().catch(() => result.statusText);
|
|
768
|
+
console.log(`[ai] embed failed: ${result.status} — ${errText}`);
|
|
769
|
+
res.writeHead(result.status, { "Content-Type": "application/json" });
|
|
770
|
+
res.end(JSON.stringify({ error: `Embed request failed (${result.status}): ${errText}` }));
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const data = await result.json();
|
|
774
|
+
console.log(`[ai] embedded ${data.vectors?.length ?? 0} vectors (768-dim)`);
|
|
775
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
776
|
+
res.end(JSON.stringify(data));
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
console.log(`[ai] embed error: ${err}`);
|
|
780
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
781
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function parseDevOverrides(config) {
|
|
785
|
+
const dev = config.dev;
|
|
786
|
+
if (!dev)
|
|
787
|
+
return {};
|
|
788
|
+
const overrides = {};
|
|
789
|
+
for (const [k, v] of Object.entries(dev)) {
|
|
790
|
+
if (typeof v === "string")
|
|
791
|
+
overrides[k] = v;
|
|
792
|
+
}
|
|
793
|
+
return overrides;
|
|
794
|
+
}
|
|
795
|
+
function loadBranding(cwd, config) {
|
|
796
|
+
const raw = config.branding;
|
|
797
|
+
if (!raw)
|
|
798
|
+
return undefined;
|
|
799
|
+
const branding = {};
|
|
800
|
+
const resolvePath = (p) => {
|
|
801
|
+
if (p.startsWith("http"))
|
|
802
|
+
return p;
|
|
803
|
+
const abs = resolve(cwd, p);
|
|
804
|
+
return `/_branding/${encodeURIComponent(abs)}`;
|
|
805
|
+
};
|
|
806
|
+
if (raw.logo)
|
|
807
|
+
branding.logo = resolvePath(raw.logo);
|
|
808
|
+
if (raw.logoSquare)
|
|
809
|
+
branding.logoSquare = resolvePath(raw.logoSquare);
|
|
810
|
+
if (raw.accentColor)
|
|
811
|
+
branding.accentColor = raw.accentColor;
|
|
812
|
+
return branding;
|
|
813
|
+
}
|
|
814
|
+
export async function dev(options = {}) {
|
|
815
|
+
checkCliVersion();
|
|
816
|
+
const cwd = process.cwd();
|
|
817
|
+
const mugJsonPath = join(cwd, "mug.json");
|
|
818
|
+
if (!existsSync(mugJsonPath)) {
|
|
819
|
+
console.error("No mug.json found. Run `mug init` first.");
|
|
820
|
+
process.exit(1);
|
|
821
|
+
}
|
|
822
|
+
ensureDevEmail(mugJsonPath);
|
|
823
|
+
ensureSettings(mugJsonPath);
|
|
824
|
+
migrateMugJsonSources(mugJsonPath);
|
|
825
|
+
repairScaffolding(cwd);
|
|
826
|
+
const config = JSON.parse(readFileSync(mugJsonPath, "utf-8"));
|
|
827
|
+
if (options.port) {
|
|
828
|
+
USER_PORT = options.port;
|
|
829
|
+
WRANGLER_PORT = options.port + 1;
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
const ports = await findAvailablePorts(8787);
|
|
833
|
+
USER_PORT = ports.userPort;
|
|
834
|
+
WRANGLER_PORT = ports.wranglerPort;
|
|
835
|
+
}
|
|
836
|
+
const name = config.name;
|
|
837
|
+
const secrets = readSecrets(cwd);
|
|
838
|
+
const wranglerToml = await generateWranglerToml(cwd, name, config, secrets);
|
|
839
|
+
const wranglerPath = join(cwd, "wrangler.toml");
|
|
840
|
+
writeFileSync(wranglerPath, wranglerToml);
|
|
841
|
+
let branding = loadBranding(cwd, config);
|
|
842
|
+
let devOverrides = parseDevOverrides(config);
|
|
843
|
+
let surfaceConfigs = loadSurfaceConfigs(cwd);
|
|
844
|
+
// Startup diagnostics
|
|
845
|
+
const diagnostics = [];
|
|
846
|
+
const sources = (config.sources ?? config.connectors ?? {});
|
|
847
|
+
for (const [srcName, src] of Object.entries(sources)) {
|
|
848
|
+
if (src.auth?.value && /^[A-Z][A-Z0-9_]+$/.test(src.auth.value) && !secrets[src.auth.value]) {
|
|
849
|
+
diagnostics.push(` ⚠ Secret ${src.auth.value} not set (used by source "${srcName}") — run: mug secret set ${src.auth.value}`);
|
|
850
|
+
}
|
|
851
|
+
if (src.syncs) {
|
|
852
|
+
const missingDbs = new Set();
|
|
853
|
+
for (const [, syncCfg] of Object.entries(src.syncs)) {
|
|
854
|
+
const dbName = syncCfg.database;
|
|
855
|
+
if (dbName && !missingDbs.has(dbName) && !existsSync(join(cwd, "databases", `${dbName}.db`))) {
|
|
856
|
+
missingDbs.add(dbName);
|
|
857
|
+
diagnostics.push(` ℹ Database "${dbName}" not found — will be created on first sync`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
for (const [id, errMsg] of surfaceErrors) {
|
|
863
|
+
diagnostics.push(` ⚠ Surface "${id}" has config errors: ${errMsg}`);
|
|
864
|
+
}
|
|
865
|
+
const reachabilityChecks = [];
|
|
866
|
+
for (const [srcName, src] of Object.entries(sources)) {
|
|
867
|
+
if (src.baseUrl) {
|
|
868
|
+
reachabilityChecks.push(fetch(src.baseUrl, { method: "HEAD", signal: AbortSignal.timeout(2000) })
|
|
869
|
+
.then((r) => { if (!r.ok)
|
|
870
|
+
diagnostics.push(` ⚠ Source "${srcName}": ${src.baseUrl} returned ${r.status}`); })
|
|
871
|
+
.catch(() => { diagnostics.push(` ⚠ Source "${srcName}": ${src.baseUrl} — unreachable`); }));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
await Promise.all(reachabilityChecks);
|
|
875
|
+
if (diagnostics.length > 0) {
|
|
876
|
+
console.log("\nDiagnostics:");
|
|
877
|
+
for (const d of diagnostics)
|
|
878
|
+
console.log(d);
|
|
879
|
+
}
|
|
880
|
+
watch(mugJsonPath, () => {
|
|
881
|
+
try {
|
|
882
|
+
const updated = JSON.parse(readFileSync(mugJsonPath, "utf-8"));
|
|
883
|
+
branding = loadBranding(cwd, updated);
|
|
884
|
+
devOverrides = parseDevOverrides(updated);
|
|
885
|
+
const overrideInfo = Object.entries(devOverrides).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
886
|
+
console.log(`[config] Reloaded${branding?.accentColor ? ` (accent: ${branding.accentColor})` : ""}${overrideInfo ? ` (dev: ${overrideInfo})` : ""}`);
|
|
887
|
+
debouncedBroadcast("config");
|
|
888
|
+
}
|
|
889
|
+
catch { }
|
|
890
|
+
});
|
|
891
|
+
const surfacesDir = join(cwd, "src", "surfaces");
|
|
892
|
+
if (existsSync(surfacesDir)) {
|
|
893
|
+
watch(surfacesDir, () => {
|
|
894
|
+
surfaceConfigs = loadSurfaceConfigs(cwd);
|
|
895
|
+
const ids = [...surfaceConfigs.keys()];
|
|
896
|
+
if (ids.length > 0) {
|
|
897
|
+
console.log(`[surfaces] Reloaded: ${ids.join(", ")}`);
|
|
898
|
+
}
|
|
899
|
+
debouncedBroadcast("surfaces");
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
const watchDirs = [
|
|
903
|
+
{ dir: join(cwd, "src", "workflows"), area: "workflows" },
|
|
904
|
+
{ dir: join(cwd, "src", "agents"), area: "agents" },
|
|
905
|
+
{ dir: join(cwd, "src", "connectors"), area: "sources" },
|
|
906
|
+
];
|
|
907
|
+
for (const { dir, area, delay } of watchDirs) {
|
|
908
|
+
if (existsSync(dir)) {
|
|
909
|
+
watch(dir, () => {
|
|
910
|
+
console.log(`[${area}] Change detected`);
|
|
911
|
+
debouncedBroadcast(area, delay);
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const dbDir = join(cwd, "databases");
|
|
916
|
+
if (existsSync(dbDir)) {
|
|
917
|
+
let knownDbFiles = new Set(readdirSync(dbDir).filter(f => f.endsWith(".db")));
|
|
918
|
+
watch(dbDir, () => {
|
|
919
|
+
const current = new Set(readdirSync(dbDir).filter(f => f.endsWith(".db")));
|
|
920
|
+
const added = [...current].filter(f => !knownDbFiles.has(f));
|
|
921
|
+
const removed = [...knownDbFiles].filter(f => !current.has(f));
|
|
922
|
+
if (added.length > 0 || removed.length > 0) {
|
|
923
|
+
if (added.length)
|
|
924
|
+
console.log(`[db] New: ${added.join(", ")}`);
|
|
925
|
+
if (removed.length)
|
|
926
|
+
console.log(`[db] Removed: ${removed.join(", ")}`);
|
|
927
|
+
knownDbFiles = current;
|
|
928
|
+
debouncedBroadcast("databases", 2000);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
const require = createRequire(import.meta.url);
|
|
933
|
+
const wranglerBin = dirname(require.resolve("wrangler/package.json"));
|
|
934
|
+
const wranglerJs = join(wranglerBin, "bin", "wrangler.js");
|
|
935
|
+
const cliRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
936
|
+
const explorerScript = join(cliRoot, "scripts", "build-explorer.ts");
|
|
937
|
+
if (existsSync(explorerScript)) {
|
|
938
|
+
const tsxBin = join(cliRoot, "node_modules", ".bin", "tsx");
|
|
939
|
+
const explorerWatcher = spawn(tsxBin, [explorerScript, "--watch"], {
|
|
940
|
+
cwd: cliRoot,
|
|
941
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
942
|
+
});
|
|
943
|
+
explorerWatcher.stdout?.on("data", (data) => {
|
|
944
|
+
const line = data.toString().trim();
|
|
945
|
+
if (line)
|
|
946
|
+
console.log(`[explorer] ${line}`);
|
|
947
|
+
});
|
|
948
|
+
explorerWatcher.stderr?.on("data", (data) => {
|
|
949
|
+
const line = data.toString().trim();
|
|
950
|
+
if (line && !line.includes("warning"))
|
|
951
|
+
console.log(`[explorer] ${line}`);
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
console.log(`Starting wrangler dev on port ${WRANGLER_PORT}...`);
|
|
955
|
+
const wrangler = spawn("node", [wranglerJs, "dev", "--port", String(WRANGLER_PORT)], {
|
|
956
|
+
cwd,
|
|
957
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
958
|
+
});
|
|
959
|
+
let wranglerReady = false;
|
|
960
|
+
let wranglerReadyCount = 0;
|
|
961
|
+
wrangler.stdout?.on("data", (data) => {
|
|
962
|
+
const line = data.toString().trim();
|
|
963
|
+
if (line)
|
|
964
|
+
console.log(`[wrangler] ${line}`);
|
|
965
|
+
if (line.includes("Ready")) {
|
|
966
|
+
wranglerReadyCount++;
|
|
967
|
+
if (wranglerReadyCount === 1) {
|
|
968
|
+
wranglerReady = true;
|
|
969
|
+
seedDatabasesToDOs(cwd).catch((err) => {
|
|
970
|
+
console.warn(`[db] Seed failed: ${err instanceof Error ? err.message : err}`);
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
console.log(`[ws] Worker restarted — reloading browsers`);
|
|
975
|
+
broadcast({ type: "reload" });
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
let errorBuffer = "";
|
|
980
|
+
wrangler.stderr?.on("data", (data) => {
|
|
981
|
+
const line = data.toString().trim();
|
|
982
|
+
if (line)
|
|
983
|
+
console.log(`[wrangler] ${line}`);
|
|
984
|
+
if (line.includes("Build failed") || line.includes("ERROR") || line.includes("✘")) {
|
|
985
|
+
errorBuffer += line + "\n";
|
|
986
|
+
}
|
|
987
|
+
if (errorBuffer && (line.includes("Build failed") || line === "" || line.includes("Finished"))) {
|
|
988
|
+
lastWranglerError = errorBuffer.trim();
|
|
989
|
+
errorBuffer = "";
|
|
990
|
+
broadcast({ type: "error", message: lastWranglerError });
|
|
991
|
+
}
|
|
992
|
+
if (line.includes("Ready")) {
|
|
993
|
+
lastWranglerError = null;
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
wrangler.on("exit", (code) => {
|
|
997
|
+
console.error(`Wrangler exited with code ${code}`);
|
|
998
|
+
process.exit(code ?? 1);
|
|
999
|
+
});
|
|
1000
|
+
const QUIET_PATHS = new Set(["/favicon.svg", "/favicon.ico", "/explorer/bundle.js", "/health"]);
|
|
1001
|
+
const server = createServer(async (req, res) => {
|
|
1002
|
+
const startTime = Date.now();
|
|
1003
|
+
const url = new URL(req.url ?? "/", `http://localhost:${USER_PORT}`);
|
|
1004
|
+
const path = url.pathname;
|
|
1005
|
+
if (!QUIET_PATHS.has(path) && !path.startsWith("/_branding/") && !path.startsWith("/_files/") && !path.startsWith("/_og")) {
|
|
1006
|
+
res.on("finish", () => {
|
|
1007
|
+
const duration = Date.now() - startTime;
|
|
1008
|
+
const method = (req.method ?? "GET").padEnd(4);
|
|
1009
|
+
const status = res.statusCode;
|
|
1010
|
+
let tag = "proxy";
|
|
1011
|
+
if (path.startsWith("/explorer"))
|
|
1012
|
+
tag = "explorer";
|
|
1013
|
+
else if (path === "/_notify/send")
|
|
1014
|
+
tag = "notify";
|
|
1015
|
+
else if (path === "/_ai/complete" || path === "/_ai/embed")
|
|
1016
|
+
tag = "ai";
|
|
1017
|
+
else if (path.startsWith("/run/"))
|
|
1018
|
+
tag = "run";
|
|
1019
|
+
else if (surfaceConfigs.has(path.split("/")[1]) || path === "/")
|
|
1020
|
+
tag = "surface";
|
|
1021
|
+
const color = status < 300 ? "\x1b[32m" : status < 400 ? "\x1b[33m" : status < 500 ? "\x1b[33m" : "\x1b[31m";
|
|
1022
|
+
const reset = "\x1b[0m";
|
|
1023
|
+
console.log(`${color}[${tag}]${reset} ${method} ${path} ${color}${status}${reset} ${duration}ms`);
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
if (path === "/_notify/send" && req.method === "POST") {
|
|
1027
|
+
return handleNotifySend(req, res, devOverrides);
|
|
1028
|
+
}
|
|
1029
|
+
if (path === "/_ai/complete" && req.method === "POST") {
|
|
1030
|
+
return handleAiComplete(req, res);
|
|
1031
|
+
}
|
|
1032
|
+
if (path === "/_ai/embed" && req.method === "POST") {
|
|
1033
|
+
return handleAiEmbed(req, res);
|
|
1034
|
+
}
|
|
1035
|
+
if (path.startsWith("/_files/")) {
|
|
1036
|
+
const filePath = join(cwd, "files", path.slice("/_files/".length));
|
|
1037
|
+
if (!existsSync(filePath)) {
|
|
1038
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1039
|
+
res.end(JSON.stringify({ error: "File not found" }));
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
1043
|
+
const mimeTypes = {
|
|
1044
|
+
html: "text/html", css: "text/css", js: "application/javascript", json: "application/json",
|
|
1045
|
+
txt: "text/plain", csv: "text/csv", xml: "application/xml",
|
|
1046
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", svg: "image/svg+xml",
|
|
1047
|
+
webp: "image/webp", gif: "image/gif", pdf: "application/pdf",
|
|
1048
|
+
};
|
|
1049
|
+
res.writeHead(200, { "Content-Type": mimeTypes[ext ?? ""] ?? "application/octet-stream", "Cache-Control": "no-cache" });
|
|
1050
|
+
res.end(readFileSync(filePath));
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (path.startsWith("/_branding/")) {
|
|
1054
|
+
const encoded = path.slice("/_branding/".length);
|
|
1055
|
+
const filePath = decodeURIComponent(encoded);
|
|
1056
|
+
if (!existsSync(filePath)) {
|
|
1057
|
+
res.writeHead(404);
|
|
1058
|
+
res.end("Not found");
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
1062
|
+
const mimeTypes = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", svg: "image/svg+xml", webp: "image/webp", gif: "image/gif" };
|
|
1063
|
+
res.writeHead(200, { "Content-Type": mimeTypes[ext ?? ""] ?? "application/octet-stream", "Cache-Control": "no-cache" });
|
|
1064
|
+
res.end(readFileSync(filePath));
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (path === "/_og-image.png") {
|
|
1068
|
+
const localOg = join(cwd, "branding", "og-image.png");
|
|
1069
|
+
if (existsSync(localOg)) {
|
|
1070
|
+
res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-cache" });
|
|
1071
|
+
res.end(readFileSync(localOg));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
res.writeHead(404);
|
|
1075
|
+
res.end("Not found");
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (path === "/favicon.svg" || path === "/favicon.ico") {
|
|
1079
|
+
const svg = branding?.accentColor
|
|
1080
|
+
? MUG_FAVICON_SVG.replaceAll("#71B7FB", branding.accentColor)
|
|
1081
|
+
: MUG_FAVICON_SVG;
|
|
1082
|
+
res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
|
|
1083
|
+
res.end(svg);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (path === "/health") {
|
|
1087
|
+
return proxyToWrangler(req, res, path + url.search);
|
|
1088
|
+
}
|
|
1089
|
+
if (path === "/explorer/bundle.js") {
|
|
1090
|
+
let bundlePath = join(cliRoot, "dist", "explorer.js");
|
|
1091
|
+
if (!existsSync(bundlePath))
|
|
1092
|
+
bundlePath = join(cliRoot, "explorer.js");
|
|
1093
|
+
if (existsSync(bundlePath)) {
|
|
1094
|
+
res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache" });
|
|
1095
|
+
res.end(readFileSync(bundlePath));
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
res.writeHead(404);
|
|
1099
|
+
res.end("Explorer bundle not found. Run: npm run build:explorer");
|
|
1100
|
+
}
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (path === "/explorer" || path.startsWith("/explorer/")) {
|
|
1104
|
+
if (path.startsWith("/explorer/api/")) {
|
|
1105
|
+
return handleExplorerApi(req, res, path, url, cwd, config, surfaceConfigs);
|
|
1106
|
+
}
|
|
1107
|
+
const accentColor = branding?.accentColor ?? "#71B7FB";
|
|
1108
|
+
const html = explorerShellHtml(name, accentColor);
|
|
1109
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1110
|
+
res.end(html);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (path === "/") {
|
|
1114
|
+
const identity = getDevIdentity(req);
|
|
1115
|
+
const allSurfaces = [...surfaceConfigs.entries()]
|
|
1116
|
+
.filter(([id]) => id !== "_home")
|
|
1117
|
+
.map(([id, cfg]) => ({
|
|
1118
|
+
surfaceId: id,
|
|
1119
|
+
title: cfg.title ?? id,
|
|
1120
|
+
description: cfg.description,
|
|
1121
|
+
isPublic: cfg.access?.mode === "public",
|
|
1122
|
+
}));
|
|
1123
|
+
const homeConfig = surfaceConfigs.get("_home");
|
|
1124
|
+
const homeScreenLayout = homeConfig ? homeConfig : null;
|
|
1125
|
+
const html = renderWorkspaceHome({ workspace: name, title: homeScreenLayout?.title, description: homeScreenLayout?.description, accentColor: branding?.accentColor ?? null, branding }, allSurfaces, identity || "dev", homeScreenLayout);
|
|
1126
|
+
const allAuthUsers = new Set();
|
|
1127
|
+
for (const [id, cfg] of surfaceConfigs) {
|
|
1128
|
+
if (id === "_home")
|
|
1129
|
+
continue;
|
|
1130
|
+
const users = cfg.type === "portal"
|
|
1131
|
+
? await getAuthUsers(cfg)
|
|
1132
|
+
: await getFormAuthUsers(cfg, name);
|
|
1133
|
+
if (users)
|
|
1134
|
+
users.forEach(u => allAuthUsers.add(u));
|
|
1135
|
+
}
|
|
1136
|
+
const banner = renderDevBanner(identity, allAuthUsers.size > 0 ? [...allAuthUsers].sort() : undefined, branding?.accentColor, name, "Workspace Home Surface");
|
|
1137
|
+
const withBanner = injectBanner(html, banner);
|
|
1138
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1139
|
+
res.end(withBanner);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const segments = path.slice(1).split("/");
|
|
1143
|
+
const surfaceId = segments[0];
|
|
1144
|
+
const surfaceAction = segments[1];
|
|
1145
|
+
const surfaceConfig = surfaceConfigs.get(surfaceId);
|
|
1146
|
+
const configError = surfaceErrors.get(surfaceId);
|
|
1147
|
+
if (configError) {
|
|
1148
|
+
const accept = req.headers.accept ?? "";
|
|
1149
|
+
if (accept.includes("text/html")) {
|
|
1150
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
1151
|
+
res.end(renderErrorOverlay("Surface Config Error", configError, `src/surfaces/${surfaceId}.json`, "Fix the error and save — the page will reload automatically."));
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (surfaceConfig && surfaceConfig.type === "portal") {
|
|
1156
|
+
const portalConfig = surfaceConfig;
|
|
1157
|
+
portalConfig.surfaceId = portalConfig.surfaceId ?? surfaceId;
|
|
1158
|
+
portalConfig.workspace = portalConfig.workspace ?? name;
|
|
1159
|
+
portalConfig.timezone = portalConfig.timezone ?? config.settings?.timezone;
|
|
1160
|
+
const basePath = `/${surfaceId}`;
|
|
1161
|
+
if (!surfaceAction) {
|
|
1162
|
+
return handlePortalList(portalConfig, req, res, basePath, branding);
|
|
1163
|
+
}
|
|
1164
|
+
if (surfaceAction === "row" && segments[2]) {
|
|
1165
|
+
return handlePortalDetail(portalConfig, req, res, basePath, segments[2], branding);
|
|
1166
|
+
}
|
|
1167
|
+
if (surfaceAction === "action" && req.method === "POST") {
|
|
1168
|
+
return handlePortalAction(portalConfig, req, res);
|
|
1169
|
+
}
|
|
1170
|
+
if (["auth", "verify", "verify-code"].includes(surfaceAction)) {
|
|
1171
|
+
return proxyToWrangler(req, res, path + url.search);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (surfaceConfig) {
|
|
1175
|
+
if (!surfaceAction) {
|
|
1176
|
+
const accept = req.headers.accept ?? "";
|
|
1177
|
+
if (accept.includes("text/html")) {
|
|
1178
|
+
const identity = getDevIdentity(req);
|
|
1179
|
+
const basePath = `/${surfaceId}`;
|
|
1180
|
+
const normalized = { ...surfaceConfig };
|
|
1181
|
+
if (!normalized.pages && normalized.fields) {
|
|
1182
|
+
normalized.pages = [{ id: "main", fields: normalized.fields }];
|
|
1183
|
+
}
|
|
1184
|
+
if (!normalized.access) {
|
|
1185
|
+
normalized.access = { mode: "public" };
|
|
1186
|
+
}
|
|
1187
|
+
const authUsers = await getFormAuthUsers(normalized, name);
|
|
1188
|
+
const surfaceType = surfaceConfig.type === "portal" ? "Portal" : "Form";
|
|
1189
|
+
const surfaceBranding = normalized.branding;
|
|
1190
|
+
const mergedBranding = surfaceBranding || branding ? { ...branding, ...surfaceBranding } : undefined;
|
|
1191
|
+
const banner = renderDevBanner(identity, authUsers, mergedBranding?.accentColor ?? branding?.accentColor, name, `${surfaceConfig.title ?? surfaceId} ${surfaceType}`);
|
|
1192
|
+
const brandedConfig = mergedBranding ? { ...normalized, branding: mergedBranding } : normalized;
|
|
1193
|
+
try {
|
|
1194
|
+
const session = makeDevSession(identity);
|
|
1195
|
+
const allFields = (normalized.pages ?? []).flatMap((p) => p.fields ?? []);
|
|
1196
|
+
const prefillValues = await resolveDevPrefill(normalized, allFields, session, name, url.searchParams);
|
|
1197
|
+
const breadcrumb = parseBreadcrumb(url);
|
|
1198
|
+
const html = renderForm(brandedConfig, session, null, basePath, prefillValues, breadcrumb);
|
|
1199
|
+
const injected = injectBanner(html, banner);
|
|
1200
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1201
|
+
res.end(injected);
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1205
|
+
const stack = err instanceof Error ? err.stack ?? "" : "";
|
|
1206
|
+
console.error(`\n❌ SURFACE ERROR: /${surfaceId}\n ${msg}\n Fix: check src/surfaces/${surfaceId}.json\n`);
|
|
1207
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
1208
|
+
res.end(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Surface Error</title>
|
|
1209
|
+
<style>body{max-width:700px;margin:60px auto;font-family:system-ui,sans-serif;padding:0 20px}
|
|
1210
|
+
h1{color:#c0392b;font-size:20px}pre{background:#f5f5f5;padding:16px;border-radius:6px;overflow-x:auto;font-size:13px;line-height:1.5}
|
|
1211
|
+
p{color:#666;font-size:14px}</style></head>
|
|
1212
|
+
<body><h1>Error rendering surface "${surfaceId}"</h1>
|
|
1213
|
+
<pre>${msg}\n\n${stack}</pre>
|
|
1214
|
+
<p>Fix the error in <code>src/surfaces/${surfaceId}.json</code> and reload.</p></body></html>`);
|
|
1215
|
+
}
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1219
|
+
res.end(JSON.stringify({ surface: surfaceConfig, session: null }));
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (surfaceAction === "submit" && req.method === "POST") {
|
|
1223
|
+
return handleFormSubmit(req, res, surfaceConfig, surfaceId, name);
|
|
1224
|
+
}
|
|
1225
|
+
if (["auth", "verify", "verify-code", "upload", "action"].includes(surfaceAction)) {
|
|
1226
|
+
return proxyToWrangler(req, res, path + url.search);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
const WRANGLER_PREFIXES = new Set(["api", "sync", "run", "health", "create-workflow"]);
|
|
1230
|
+
if (segments.length === 1 && !WRANGLER_PREFIXES.has(surfaceId) && !surfaceConfig) {
|
|
1231
|
+
const accept = req.headers.accept ?? "";
|
|
1232
|
+
if (accept.includes("text/html")) {
|
|
1233
|
+
let available = "";
|
|
1234
|
+
for (const [id, cfg] of surfaceConfigs) {
|
|
1235
|
+
const typeLabel = cfg.type === "portal" ? "portal" : "form";
|
|
1236
|
+
available += `<li><a href="/${id}">/${id}</a> (${typeLabel})</li>`;
|
|
1237
|
+
}
|
|
1238
|
+
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Surface not found</title>
|
|
1239
|
+
<style>body{max-width:600px;margin:60px auto;font-family:system-ui,sans-serif;color:#333;padding:0 20px}
|
|
1240
|
+
h1{color:#c0392b;font-size:22px}h3{margin-top:24px;font-size:15px}pre{background:#f5f5f5;padding:12px;border-radius:6px;overflow-x:auto}
|
|
1241
|
+
ul{list-style:none;padding:0}li{padding:4px 0}a{color:#2563eb}p.hint{color:#888;margin-top:32px;font-size:13px}</style></head>
|
|
1242
|
+
<body><h1>Surface "${surfaceId}" not found</h1>
|
|
1243
|
+
${available ? `<h3>Available surfaces:</h3><ul>${available}</ul>` : "<p>No surfaces configured yet.</p>"}
|
|
1244
|
+
<h3>To create this surface:</h3>
|
|
1245
|
+
<pre>mug form init ${surfaceId} # create a form\nmug portal init ${surfaceId} # create a portal</pre>
|
|
1246
|
+
<p class="hint">Surface configs live in src/surfaces/</p></body></html>`;
|
|
1247
|
+
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
1248
|
+
res.end(html);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
if (path.startsWith("/hook/") && req.method === "POST") {
|
|
1253
|
+
const workflowName = path.slice("/hook/".length).split("/").pop() ?? "";
|
|
1254
|
+
const start = Date.now();
|
|
1255
|
+
try {
|
|
1256
|
+
const body = await readBody(req);
|
|
1257
|
+
const upstream = await fetch(`http://localhost:${WRANGLER_PORT}/run/${workflowName}`, {
|
|
1258
|
+
method: "POST",
|
|
1259
|
+
headers: { "Content-Type": "application/json" },
|
|
1260
|
+
body: body.length > 0 ? new Uint8Array(body) : JSON.stringify({}),
|
|
1261
|
+
});
|
|
1262
|
+
const result = await upstream.json();
|
|
1263
|
+
writebackDOsToFiles(cwd).catch(() => { });
|
|
1264
|
+
broadcast({ type: "refresh", area: "workflows" });
|
|
1265
|
+
const dur = Date.now() - start;
|
|
1266
|
+
if (result.webhookResponse) {
|
|
1267
|
+
const wr = result.webhookResponse;
|
|
1268
|
+
const respBody = typeof wr.body === "string" ? wr.body : JSON.stringify(wr.body);
|
|
1269
|
+
const ct = typeof wr.body === "string" ? "text/plain" : "application/json";
|
|
1270
|
+
console.log(`\x1b[35m[webhook]\x1b[0m POST /hook/${workflowName} ${wr.status} ${dur}ms (respond)`);
|
|
1271
|
+
res.writeHead(wr.status, { "Content-Type": ct });
|
|
1272
|
+
res.end(respBody);
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
console.log(`\x1b[35m[webhook]\x1b[0m POST /hook/${workflowName} 200 ${dur}ms`);
|
|
1276
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1277
|
+
res.end(JSON.stringify({ ok: true, id: result.runId }));
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
catch (err) {
|
|
1281
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1282
|
+
console.log(`\x1b[31m[webhook]\x1b[0m POST /hook/${workflowName} failed: ${msg}`);
|
|
1283
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1284
|
+
res.end(JSON.stringify({ error: msg }));
|
|
1285
|
+
}
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (path.startsWith("/_dev/run/") && req.method === "POST") {
|
|
1289
|
+
const workflowName = path.slice("/_dev/run/".length);
|
|
1290
|
+
console.log(`\x1b[36m[run]\x1b[0m ${workflowName} starting...`);
|
|
1291
|
+
try {
|
|
1292
|
+
const body = await readBody(req);
|
|
1293
|
+
const upstream = await fetch(`http://localhost:${WRANGLER_PORT}/run/${workflowName}`, {
|
|
1294
|
+
method: "POST",
|
|
1295
|
+
headers: { "Content-Type": "application/json" },
|
|
1296
|
+
body: body.length > 0 ? new Uint8Array(body) : JSON.stringify({}),
|
|
1297
|
+
});
|
|
1298
|
+
const result = await upstream.json();
|
|
1299
|
+
const steps = result.steps ?? [];
|
|
1300
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1301
|
+
const s = steps[i];
|
|
1302
|
+
const icon = s.error ? "\x1b[31m✗\x1b[0m" : "\x1b[32m✓\x1b[0m";
|
|
1303
|
+
const dur = s.durationMs != null ? ` ${s.durationMs}ms` : "";
|
|
1304
|
+
const detail = s.output ? ` — ${s.output.slice(0, 80)}` : s.error ? ` — ${s.error.slice(0, 80)}` : "";
|
|
1305
|
+
console.log(`\x1b[36m[run]\x1b[0m ${i + 1}/${steps.length}: ${s.name} ${icon}${dur}${detail}`);
|
|
1306
|
+
}
|
|
1307
|
+
const statusColor = result.status === "complete" ? "\x1b[32m" : "\x1b[31m";
|
|
1308
|
+
console.log(`\x1b[36m[run]\x1b[0m ${workflowName} ${statusColor}${result.status}\x1b[0m in ${result.durationMs}ms (${result.stepCount} steps)`);
|
|
1309
|
+
writebackDOsToFiles(cwd).catch(() => { });
|
|
1310
|
+
broadcast({ type: "refresh", area: "workflows" });
|
|
1311
|
+
res.writeHead(upstream.status, { "Content-Type": "application/json" });
|
|
1312
|
+
res.end(JSON.stringify(result));
|
|
1313
|
+
}
|
|
1314
|
+
catch (err) {
|
|
1315
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1316
|
+
console.log(`\x1b[31m[run]\x1b[0m ${workflowName} failed: ${msg}`);
|
|
1317
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1318
|
+
res.end(JSON.stringify({ error: msg }));
|
|
1319
|
+
}
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (path.startsWith("/run/") && req.method === "POST") {
|
|
1323
|
+
await proxyToWrangler(req, res, path + url.search);
|
|
1324
|
+
writebackDOsToFiles(cwd).catch(() => { });
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
return proxyToWrangler(req, res, path + url.search);
|
|
1328
|
+
});
|
|
1329
|
+
wss = new WebSocketServer({ noServer: true });
|
|
1330
|
+
wss.on("connection", (ws) => {
|
|
1331
|
+
wsClients.add(ws);
|
|
1332
|
+
ws.on("close", () => wsClients.delete(ws));
|
|
1333
|
+
});
|
|
1334
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1335
|
+
const url = new URL(req.url ?? "/", `http://localhost:${USER_PORT}`);
|
|
1336
|
+
if (url.pathname === "/_dev/ws" && wss) {
|
|
1337
|
+
wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); });
|
|
1338
|
+
}
|
|
1339
|
+
else {
|
|
1340
|
+
socket.destroy();
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
const accentColor = branding?.accentColor ?? "#71B7FB";
|
|
1344
|
+
const pidData = { pid: process.pid, userPort: USER_PORT, wranglerPort: WRANGLER_PORT, workspace: name };
|
|
1345
|
+
let tunnelProcess = null;
|
|
1346
|
+
if (options.tunnel) {
|
|
1347
|
+
try {
|
|
1348
|
+
const cfPath = await new Promise((resolve) => {
|
|
1349
|
+
exec("which cloudflared", (err, stdout) => resolve(err ? null : stdout.trim()));
|
|
1350
|
+
});
|
|
1351
|
+
if (cfPath) {
|
|
1352
|
+
tunnelProcess = spawn(cfPath, ["tunnel", "--url", `http://localhost:${USER_PORT}`], {
|
|
1353
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1354
|
+
});
|
|
1355
|
+
const parseTunnelUrl = (data) => {
|
|
1356
|
+
const line = data.toString();
|
|
1357
|
+
const match = line.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
|
|
1358
|
+
if (match && !pidData.tunnelUrl) {
|
|
1359
|
+
pidData.tunnelUrl = match[1];
|
|
1360
|
+
writePidFile(cwd, pidData);
|
|
1361
|
+
console.log(` Tunnel: ${pidData.tunnelUrl}`);
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
tunnelProcess.stdout?.on("data", parseTunnelUrl);
|
|
1365
|
+
tunnelProcess.stderr?.on("data", parseTunnelUrl);
|
|
1366
|
+
tunnelProcess.on("exit", (code) => {
|
|
1367
|
+
if (!shuttingDown)
|
|
1368
|
+
console.warn(`[tunnel] cloudflared exited with code ${code}`);
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
console.log(`\n ⚠ --tunnel requires cloudflared. Install: brew install cloudflared\n`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
catch { }
|
|
1376
|
+
}
|
|
1377
|
+
let shuttingDown = false;
|
|
1378
|
+
server.listen(USER_PORT, () => {
|
|
1379
|
+
writePidFile(cwd, pidData);
|
|
1380
|
+
const nameDisplay = `\x1b[1m${name}\x1b[0m`;
|
|
1381
|
+
console.log(`\n ${nameDisplay} dev server`);
|
|
1382
|
+
console.log(` http://localhost:${USER_PORT}`);
|
|
1383
|
+
console.log(` http://localhost:${USER_PORT}/explorer`);
|
|
1384
|
+
if (USER_PORT !== 8787) {
|
|
1385
|
+
console.log(` (ports auto-selected — use --port to pin)`);
|
|
1386
|
+
}
|
|
1387
|
+
console.log(` [ws] Hot reload active`);
|
|
1388
|
+
exec(`open http://localhost:${USER_PORT}/explorer`);
|
|
1389
|
+
const overrideEntries = Object.entries(devOverrides);
|
|
1390
|
+
if (overrideEntries.length > 0) {
|
|
1391
|
+
console.log(` Dev overrides:`);
|
|
1392
|
+
for (const [channel, target] of overrideEntries) {
|
|
1393
|
+
console.log(` ${channel} → ${target}`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
console.log();
|
|
1397
|
+
const ids = [...surfaceConfigs.keys()];
|
|
1398
|
+
if (ids.length > 0) {
|
|
1399
|
+
console.log(`Surfaces:`);
|
|
1400
|
+
for (const id of ids) {
|
|
1401
|
+
const cfg = surfaceConfigs.get(id);
|
|
1402
|
+
const typeLabel = cfg.type === "portal" ? "portal" : "form";
|
|
1403
|
+
console.log(` http://localhost:${USER_PORT}/${id} (${typeLabel})`);
|
|
1404
|
+
}
|
|
1405
|
+
console.log();
|
|
1406
|
+
}
|
|
1407
|
+
else {
|
|
1408
|
+
console.log(`No surfaces found in src/surfaces/. Create one with: mug form init <name>\n`);
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
async function shutdown() {
|
|
1412
|
+
if (shuttingDown)
|
|
1413
|
+
return;
|
|
1414
|
+
shuttingDown = true;
|
|
1415
|
+
console.log("\nShutting down...");
|
|
1416
|
+
if (tunnelProcess)
|
|
1417
|
+
tunnelProcess.kill();
|
|
1418
|
+
for (const client of wsClients)
|
|
1419
|
+
client.close();
|
|
1420
|
+
if (wss)
|
|
1421
|
+
wss.close();
|
|
1422
|
+
if (wranglerReady) {
|
|
1423
|
+
await writebackDOsToFiles(cwd).catch((err) => {
|
|
1424
|
+
console.warn(`[db] Writeback failed: ${err instanceof Error ? err.message : err}`);
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
wrangler.kill();
|
|
1428
|
+
server.close();
|
|
1429
|
+
cleanPidFile(cwd);
|
|
1430
|
+
process.exit(0);
|
|
1431
|
+
}
|
|
1432
|
+
process.on("SIGINT", () => shutdown());
|
|
1433
|
+
process.on("SIGTERM", () => shutdown());
|
|
1434
|
+
}
|
|
1435
|
+
async function generateWranglerToml(cwd, name, config, secrets) {
|
|
1436
|
+
let toml = `# Auto-generated by mug dev — do not edit
|
|
1437
|
+
name = "${name}"
|
|
1438
|
+
main = "src/index.ts"
|
|
1439
|
+
compatibility_date = "2025-05-08"
|
|
1440
|
+
compatibility_flags = ["nodejs_compat"]
|
|
1441
|
+
|
|
1442
|
+
[durable_objects]
|
|
1443
|
+
bindings = [{ name = "WORKSPACE_DB", class_name = "WorkspaceDatabase" }]
|
|
1444
|
+
|
|
1445
|
+
[[migrations]]
|
|
1446
|
+
tag = "v1"
|
|
1447
|
+
new_sqlite_classes = ["WorkspaceDatabase"]
|
|
1448
|
+
|
|
1449
|
+
[vars]
|
|
1450
|
+
ENVIRONMENT = "development"
|
|
1451
|
+
WORKSPACE_ID = "${name}"
|
|
1452
|
+
`;
|
|
1453
|
+
const sources = config.sources ?? {};
|
|
1454
|
+
if (Object.keys(sources).length > 0) {
|
|
1455
|
+
toml += `MUG_SOURCES = '${JSON.stringify(sources)}'\n`;
|
|
1456
|
+
}
|
|
1457
|
+
const rawBranding = config.branding;
|
|
1458
|
+
if (rawBranding) {
|
|
1459
|
+
const devBranding = {};
|
|
1460
|
+
const subdomain = config.subdomain ?? name;
|
|
1461
|
+
for (const key of ["logo", "logoSquare"]) {
|
|
1462
|
+
const raw = rawBranding[key];
|
|
1463
|
+
if (!raw)
|
|
1464
|
+
continue;
|
|
1465
|
+
const localPath = String(raw);
|
|
1466
|
+
if (localPath.startsWith("http://") || localPath.startsWith("https://")) {
|
|
1467
|
+
devBranding[key] = localPath;
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
const ext = localPath.split(".").pop()?.toLowerCase() ?? "png";
|
|
1471
|
+
const serveName = `${key === "logoSquare" ? "logo-square" : "logo"}.${ext}`;
|
|
1472
|
+
const prodUrl = `https://${subdomain}.mug.work/_branding/${serveName}`;
|
|
1473
|
+
try {
|
|
1474
|
+
const res = await fetch(prodUrl, { method: "HEAD" });
|
|
1475
|
+
if (res.ok) {
|
|
1476
|
+
devBranding[key] = prodUrl;
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
catch { }
|
|
1481
|
+
const fullPath = join(cwd, localPath);
|
|
1482
|
+
if (existsSync(fullPath)) {
|
|
1483
|
+
const mimeTypes = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", svg: "image/svg+xml", webp: "image/webp", gif: "image/gif" };
|
|
1484
|
+
devBranding[key] = `data:${mimeTypes[ext] ?? "image/png"};base64,${readFileSync(fullPath).toString("base64")}`;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (rawBranding.accentColor)
|
|
1488
|
+
devBranding.accentColor = String(rawBranding.accentColor);
|
|
1489
|
+
toml += `MUG_BRANDING = '${JSON.stringify(devBranding)}'\n`;
|
|
1490
|
+
}
|
|
1491
|
+
const aiConfig = config.ai;
|
|
1492
|
+
if (aiConfig?.routing) {
|
|
1493
|
+
toml += `MUG_AI_ROUTING = '${JSON.stringify(aiConfig.routing)}'\n`;
|
|
1494
|
+
}
|
|
1495
|
+
if (aiConfig?.billing) {
|
|
1496
|
+
toml += `MUG_AI_BILLING = '${JSON.stringify(aiConfig.billing)}'\n`;
|
|
1497
|
+
}
|
|
1498
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
1499
|
+
toml += `${key} = "${value.replace(/"/g, '\\"')}"\n`;
|
|
1500
|
+
}
|
|
1501
|
+
const workflows = config.workflows ?? {};
|
|
1502
|
+
const devSources = config.sources ?? {};
|
|
1503
|
+
const syncCrons = Object.values(devSources).flatMap((s) => Object.values(s.syncs ?? {}).map((sync) => sync.schedule));
|
|
1504
|
+
const crons = [
|
|
1505
|
+
...Object.values(workflows).map((w) => w.schedule),
|
|
1506
|
+
...syncCrons,
|
|
1507
|
+
].filter(Boolean);
|
|
1508
|
+
if (crons.length > 0) {
|
|
1509
|
+
toml += `\n[triggers]\ncrons = [${crons.map((c) => `"${c}"`).join(", ")}]\n`;
|
|
1510
|
+
}
|
|
1511
|
+
return toml;
|
|
1512
|
+
}
|
|
1513
|
+
function titleCase(slug) {
|
|
1514
|
+
return slug.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
1515
|
+
}
|
|
1516
|
+
function explorerShellHtml(workspaceName, accentColor) {
|
|
1517
|
+
const prettyName = titleCase(workspaceName);
|
|
1518
|
+
return `<!DOCTYPE html>
|
|
1519
|
+
<html lang="en">
|
|
1520
|
+
<head>
|
|
1521
|
+
<meta charset="UTF-8">
|
|
1522
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1523
|
+
<title>${prettyName} — Explorer</title>
|
|
1524
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" media="print" onload="this.media='all'">
|
|
1525
|
+
<style>
|
|
1526
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1527
|
+
:root { --accent: ${accentColor}; --bg: #08090C; --bg-card: #13161A; --bg-hover: #1A1E24; --border: #1E2430; --text: #E9E9EA; --text-muted: #A3A6A7; --text-dim: #6b6e70; }
|
|
1528
|
+
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
|
1529
|
+
h1, h2, h3 { font-family: 'Outfit', sans-serif; }
|
|
1530
|
+
a { color: var(--accent); text-decoration: none; }
|
|
1531
|
+
a:hover { text-decoration: underline; }
|
|
1532
|
+
|
|
1533
|
+
.explorer { max-width: 1200px; margin: 0 auto; }
|
|
1534
|
+
.explorer-header { display: flex; align-items: center; justify-content: space-between; height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
|
|
1535
|
+
.explorer-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; }
|
|
1536
|
+
.explorer-logo:hover { text-decoration: none; }
|
|
1537
|
+
.explorer-logo img { width: 22px; height: auto; display: block; }
|
|
1538
|
+
.explorer-workspace-name { font-family: 'Outfit', sans-serif; font-size: 18px; font-weight: 700; color: var(--text); letter-spacing: -0.5px; }
|
|
1539
|
+
.explorer-badge { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-muted); font-weight: 500; }
|
|
1540
|
+
.explorer-header-right { display: flex; align-items: center; gap: 24px; }
|
|
1541
|
+
.explorer-search-wrap { position: relative; }
|
|
1542
|
+
.explorer-search-input-wrap { display: flex; align-items: center; position: relative; }
|
|
1543
|
+
.explorer-search-icon, .explorer-search-input-wrap > svg { position: absolute; left: 10px; width: 14px; height: 14px; color: var(--text-dim); display: block; pointer-events: none; }
|
|
1544
|
+
.explorer-search { padding: 6px 12px 6px 30px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 13px; width: 240px; outline: none; transition: border-color 0.15s; }
|
|
1545
|
+
.explorer-search:focus { border-color: var(--accent); }
|
|
1546
|
+
.explorer-search::placeholder { color: var(--text-dim); }
|
|
1547
|
+
.explorer-search-dropdown { position: absolute; top: 100%; left: 0; right: 0; margin-top: 4px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; z-index: 30; box-shadow: 0 8px 24px rgba(0,0,0,0.4); max-height: 360px; overflow-y: auto; min-width: 320px; }
|
|
1548
|
+
.explorer-search-empty { padding: 12px 16px; font-size: 13px; color: var(--text-dim); }
|
|
1549
|
+
.explorer-search-result { display: flex; align-items: center; gap: 10px; padding: 8px 16px; text-decoration: none; color: var(--text); cursor: pointer; }
|
|
1550
|
+
.explorer-search-result:hover, .explorer-search-result.selected { background: var(--bg-hover); text-decoration: none; }
|
|
1551
|
+
.explorer-search-result-icon, .explorer-search-result svg { width: 14px; height: 14px; color: var(--text-muted); flex-shrink: 0; display: block; }
|
|
1552
|
+
.explorer-search-result-text { flex: 1; min-width: 0; }
|
|
1553
|
+
.explorer-search-result-name { font-size: 13px; font-weight: 500; display: block; }
|
|
1554
|
+
.explorer-search-result-meta { font-size: 11px; color: var(--text-muted); display: block; }
|
|
1555
|
+
.explorer-search-result-category { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
|
|
1556
|
+
.explorer-nav { display: flex; gap: 20px; }
|
|
1557
|
+
.explorer-nav-link { font-size: 14px; font-weight: 500; color: var(--text-muted); padding: 4px 8px; border-radius: 4px; display: flex; align-items: center; gap: 6px; transition: color 0.15s; white-space: nowrap; }
|
|
1558
|
+
.explorer-nav-link:hover { color: var(--text); background: var(--bg-hover); text-decoration: none; }
|
|
1559
|
+
.explorer-nav-link svg { width: 15px; height: 15px; }
|
|
1560
|
+
.explorer-nav-account { background: var(--accent); color: var(--bg) !important; padding: 6px 10px; border-radius: 4px; line-height: 0; transition: background 0.15s; }
|
|
1561
|
+
.explorer-nav-account:hover { background: #93cbfc; text-decoration: none; }
|
|
1562
|
+
.explorer-nav-account svg { width: 18px; height: 18px; }
|
|
1563
|
+
.explorer-hamburger-wrap { display: none; position: relative; }
|
|
1564
|
+
.explorer-hamburger { background: none; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; color: var(--text-muted); padding: 4px 6px; display: flex; align-items: center; transition: all 0.15s; }
|
|
1565
|
+
.explorer-hamburger:hover { color: var(--text); border-color: var(--text-dim); }
|
|
1566
|
+
.explorer-hamburger svg { width: 18px; height: 18px; }
|
|
1567
|
+
.explorer-hamburger-menu { position: absolute; right: 0; top: calc(100% + 8px); background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 4px; min-width: 220px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 200; display: flex; flex-direction: column; gap: 2px; }
|
|
1568
|
+
.explorer-hamburger-menu .explorer-nav-link { padding: 8px 12px; border-radius: 6px; }
|
|
1569
|
+
@media (max-width: 1100px) {
|
|
1570
|
+
.explorer-nav-desktop { display: none; }
|
|
1571
|
+
.explorer-hamburger-wrap { display: block; }
|
|
1572
|
+
.explorer-search { width: 160px; }
|
|
1573
|
+
}
|
|
1574
|
+
@media (max-width: 700px) {
|
|
1575
|
+
.explorer-search { width: 120px; }
|
|
1576
|
+
.explorer-badge { display: none; }
|
|
1577
|
+
}
|
|
1578
|
+
.explorer-main { padding: 16px 24px 48px; }
|
|
1579
|
+
.explorer-loading, .explorer-error, .explorer-not-found { padding: 48px 24px; text-align: center; color: var(--text-muted); }
|
|
1580
|
+
.explorer-error { color: #ef4444; }
|
|
1581
|
+
|
|
1582
|
+
.overview-header { margin-bottom: 32px; }
|
|
1583
|
+
.overview-header h1 { font-family: 'Outfit', sans-serif; font-size: 24px; font-weight: 600; color: var(--text); letter-spacing: -0.3px; }
|
|
1584
|
+
.overview-subtitle { font-size: 14px; color: var(--text-muted); }
|
|
1585
|
+
|
|
1586
|
+
.overview-grid { display: flex; flex-direction: column; gap: 32px; }
|
|
1587
|
+
.overview-section h2 { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
|
1588
|
+
a.section-header { color: var(--text-muted); text-decoration: none; display: flex; align-items: center; gap: 6px; }
|
|
1589
|
+
a.section-header:hover { color: var(--text); text-decoration: none; }
|
|
1590
|
+
.section-icon, .overview-section h2 svg { width: 15px; height: 15px; flex-shrink: 0; display: block; }
|
|
1591
|
+
.section-count { font-size: 12px; background: var(--border); color: var(--text-muted); padding: 1px 8px; border-radius: 10px; }
|
|
1592
|
+
|
|
1593
|
+
.card-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 8px; }
|
|
1594
|
+
.card { display: flex; flex-direction: column; gap: 2px; padding: 12px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; transition: background 0.15s, border-color 0.15s; }
|
|
1595
|
+
.card:hover { background: var(--bg-hover); border-color: var(--border); text-decoration: none; }
|
|
1596
|
+
.card-name { font-size: 14px; font-weight: 500; color: var(--text); }
|
|
1597
|
+
.card-meta { font-size: 12px; color: var(--text-muted); }
|
|
1598
|
+
.card-badge { font-size: 10px; padding: 1px 6px; border-radius: 4px; margin-left: 6px; font-weight: 600; vertical-align: middle; }
|
|
1599
|
+
.card-badge-remote { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
|
1600
|
+
.remote-notice { padding: 12px 16px; background: rgba(59,130,246,0.08); border: 1px solid rgba(59,130,246,0.2); border-radius: 8px; margin-bottom: 16px; font-size: 13px; color: var(--text-muted); display: flex; align-items: center; gap: 10px; }
|
|
1601
|
+
.remote-notice-icon { width: 16px; height: 16px; color: #3b82f6; flex-shrink: 0; }
|
|
1602
|
+
.remote-notice code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 12px; font-family: 'SF Mono', Menlo, Monaco, monospace; }
|
|
1603
|
+
.remote-columns { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
|
|
1604
|
+
.remote-columns .source-tables-label { width: 100%; }
|
|
1605
|
+
|
|
1606
|
+
.empty-state { font-size: 13px; color: var(--text-dim); padding: 16px; border: 1px dashed var(--border); border-radius: 8px; }
|
|
1607
|
+
|
|
1608
|
+
.breadcrumb { font-size: 13px; color: var(--text-muted); margin-bottom: 16px; }
|
|
1609
|
+
.breadcrumb a { color: var(--text-muted); }
|
|
1610
|
+
.breadcrumb a:hover { color: var(--accent); }
|
|
1611
|
+
|
|
1612
|
+
.detail-header { margin-bottom: 20px; }
|
|
1613
|
+
.detail-header-row { display: flex; align-items: center; gap: 12px; }
|
|
1614
|
+
.run-button { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 6px 14px; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
|
1615
|
+
.run-button:hover { filter: brightness(1.1); }
|
|
1616
|
+
.run-button:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
1617
|
+
.detail-header h1 { font-family: 'Outfit', sans-serif; font-size: 22px; font-weight: 600; color: var(--text); display: inline-flex; align-items: center; gap: 8px; margin-right: 12px; letter-spacing: -0.2px; }
|
|
1618
|
+
.detail-header h1 svg { width: 20px; height: 20px; flex-shrink: 0; display: block; }
|
|
1619
|
+
.detail-meta { font-size: 13px; color: var(--text-muted); }
|
|
1620
|
+
|
|
1621
|
+
.detail-relations { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; padding: 12px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; }
|
|
1622
|
+
.relation-group { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
1623
|
+
.relation-label { color: var(--text-dim); }
|
|
1624
|
+
.relation-link { color: var(--accent); padding: 2px 8px; background: rgba(37, 99, 235, 0.1); border-radius: 4px; }
|
|
1625
|
+
.relation-link:hover { background: rgba(37, 99, 235, 0.2); text-decoration: none; }
|
|
1626
|
+
|
|
1627
|
+
.connected-to { margin-bottom: 20px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); }
|
|
1628
|
+
.connected-to-toggle { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; background: none; border: none; cursor: pointer; color: var(--text-muted); }
|
|
1629
|
+
.connected-to-toggle:hover { color: var(--text); }
|
|
1630
|
+
.connected-to-empty, .connected-to-empty:hover { cursor: default; color: var(--text-muted); }
|
|
1631
|
+
.connected-to-heading { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; display: flex; align-items: center; gap: 8px; }
|
|
1632
|
+
.connected-to-summary { font-weight: 400; text-transform: none; letter-spacing: 0; color: var(--text-dim); font-size: 12px; }
|
|
1633
|
+
.connected-to-icon, .connected-to-heading > svg { width: 14px; height: 14px; display: block; flex-shrink: 0; }
|
|
1634
|
+
.connected-to-chevron, .connected-to-toggle svg { width: 14px; height: 14px; display: block; }
|
|
1635
|
+
.connected-to-body { padding: 0 20px 16px; }
|
|
1636
|
+
.connected-to .overview-section { margin-bottom: 0; }
|
|
1637
|
+
.connected-to .overview-section + .overview-section { margin-top: 16px; }
|
|
1638
|
+
|
|
1639
|
+
.detail-props { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; padding: 12px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; }
|
|
1640
|
+
.prop-row { display: flex; gap: 12px; font-size: 13px; }
|
|
1641
|
+
.prop-label { color: var(--text-dim); min-width: 80px; }
|
|
1642
|
+
.prop-value { color: var(--text); }
|
|
1643
|
+
.prop-warn { color: #f59e0b; }
|
|
1644
|
+
|
|
1645
|
+
.detail-actions { margin-bottom: 16px; }
|
|
1646
|
+
.action-link { display: inline-flex; align-items: center; justify-content: center; padding: 10px 24px; background: var(--accent); color: #08090C; border-radius: 4px; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; transition: background 0.15s; }
|
|
1647
|
+
.action-link:hover { background: #93cbfc; text-decoration: none; }
|
|
1648
|
+
|
|
1649
|
+
.file-preview { width: 100%; max-height: 120px; object-fit: cover; border-radius: 4px; margin-bottom: 4px; }
|
|
1650
|
+
|
|
1651
|
+
.table-list { display: flex; flex-direction: column; gap: 8px; }
|
|
1652
|
+
.table-card .table-columns { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
|
1653
|
+
.col-tag { font-size: 11px; padding: 1px 6px; background: var(--bg-hover); border: 1px solid var(--border); border-radius: 4px; color: var(--text-muted); }
|
|
1654
|
+
.col-tag.col-pk { border-color: var(--accent); color: var(--accent); }
|
|
1655
|
+
.col-type { color: var(--text-dim); margin-left: 2px; }
|
|
1656
|
+
|
|
1657
|
+
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
|
|
1658
|
+
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
1659
|
+
.data-table th { position: sticky; top: 0; background: var(--bg-card); padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
1660
|
+
.th-name { color: var(--text); font-weight: 600; }
|
|
1661
|
+
.th-type { color: var(--text-dim); font-weight: 400; margin-left: 4px; font-size: 11px; }
|
|
1662
|
+
.data-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1663
|
+
.data-table tr:hover td { background: var(--bg-hover); }
|
|
1664
|
+
|
|
1665
|
+
.cell-null { color: var(--text-dim); font-style: italic; }
|
|
1666
|
+
.cell-number { color: #a78bfa; font-variant-numeric: tabular-nums; }
|
|
1667
|
+
.cell-bool { color: #f59e0b; }
|
|
1668
|
+
.cell-date { color: #34d399; }
|
|
1669
|
+
.cell-email { color: var(--accent); }
|
|
1670
|
+
.cell-url { color: var(--accent); }
|
|
1671
|
+
.cell-text { color: var(--text); }
|
|
1672
|
+
|
|
1673
|
+
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
1674
|
+
.search-input { padding: 6px 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 13px; width: 220px; outline: none; transition: border-color 0.15s; }
|
|
1675
|
+
.search-input:focus { border-color: var(--accent); }
|
|
1676
|
+
.search-input::placeholder { color: var(--text-dim); }
|
|
1677
|
+
.toolbar-btn { padding: 6px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; color: var(--text-muted); cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; transition: background 0.15s, border-color 0.15s, color 0.15s; }
|
|
1678
|
+
.toolbar-btn:hover { background: var(--bg-hover); border-color: #3a4560; color: var(--text); }
|
|
1679
|
+
.toolbar-btn.active { border-color: var(--accent); color: var(--accent); }
|
|
1680
|
+
|
|
1681
|
+
.col-menu-wrap { position: relative; }
|
|
1682
|
+
.dropdown { position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 8px 0; min-width: 200px; max-height: 300px; overflow-y: auto; z-index: 10; box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
|
|
1683
|
+
.dropdown-item { display: flex; align-items: center; gap: 8px; padding: 4px 12px; font-size: 13px; color: var(--text); cursor: pointer; }
|
|
1684
|
+
.dropdown-item:hover { background: var(--bg-hover); }
|
|
1685
|
+
.dropdown-item input[type="checkbox"] { accent-color: var(--accent); }
|
|
1686
|
+
|
|
1687
|
+
.filter-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
1688
|
+
.filter-row { display: flex; align-items: center; gap: 6px; }
|
|
1689
|
+
.filter-row select, .filter-value { padding: 5px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 12px; }
|
|
1690
|
+
.filter-row select { min-width: 120px; }
|
|
1691
|
+
.filter-value { width: 160px; }
|
|
1692
|
+
.filter-remove { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
|
1693
|
+
.filter-remove:hover { color: #ef4444; }
|
|
1694
|
+
.filter-actions { display: flex; gap: 8px; margin-top: 4px; }
|
|
1695
|
+
|
|
1696
|
+
.sortable-th { cursor: pointer; user-select: none; }
|
|
1697
|
+
.sortable-th:hover { background: var(--bg-hover); }
|
|
1698
|
+
.sort-indicator { color: var(--accent); font-size: 12px; }
|
|
1699
|
+
|
|
1700
|
+
.pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; padding: 12px; }
|
|
1701
|
+
.pagination button { padding: 6px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); cursor: pointer; font-size: 13px; font-weight: 500; transition: background 0.15s, border-color 0.15s; }
|
|
1702
|
+
.pagination button:hover:not(:disabled) { background: var(--bg-hover); border-color: #3a4560; }
|
|
1703
|
+
.pagination button:disabled { opacity: 0.4; cursor: default; }
|
|
1704
|
+
.page-info { font-size: 13px; color: var(--text-muted); }
|
|
1705
|
+
|
|
1706
|
+
.step-flow { margin-top: 24px; }
|
|
1707
|
+
.step-card-wrap { position: relative; padding-left: 20px; }
|
|
1708
|
+
.step-card-wrap:not(.step-last) { padding-bottom: 0; }
|
|
1709
|
+
.step-connector { position: absolute; left: 8px; top: 0; bottom: 0; width: 2px; background: var(--border); }
|
|
1710
|
+
.step-last > .step-connector { height: 20px; }
|
|
1711
|
+
.step-card-wrap::before { content: ''; position: absolute; left: 4px; top: 16px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg-card); border: 2px solid var(--border); z-index: 1; }
|
|
1712
|
+
.step-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 8px; }
|
|
1713
|
+
.step-expandable { cursor: pointer; }
|
|
1714
|
+
.step-expandable:hover { border-color: var(--accent); }
|
|
1715
|
+
.step-card-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
1716
|
+
.step-icon, .step-card-header > svg { width: 14px; height: 14px; flex-shrink: 0; display: block; }
|
|
1717
|
+
.step-icon-query, .step-icon-exec, .step-card-header > svg[data-lucide="database"] { color: #3b82f6; }
|
|
1718
|
+
.step-icon-ai, .step-card-header > svg[data-lucide="sparkles"] { color: #a78bfa; }
|
|
1719
|
+
.step-icon-email, .step-card-header > svg[data-lucide="mail"] { color: #22c55e; }
|
|
1720
|
+
.step-icon-sms, .step-card-header > svg[data-lucide="message-square"] { color: #22c55e; }
|
|
1721
|
+
.step-icon-slack, .step-card-header > svg[data-lucide="hash"] { color: #22c55e; }
|
|
1722
|
+
.step-icon-collect, .step-card-header > svg[data-lucide="clipboard"] { color: #f59e0b; }
|
|
1723
|
+
.step-icon-return, .step-card-header > svg[data-lucide="flag"] { color: var(--text-dim); }
|
|
1724
|
+
.step-icon-loop, .step-card-header > svg[data-lucide="repeat"] { color: #3b82f6; }
|
|
1725
|
+
.step-icon-branch, .step-card-header > svg[data-lucide="git-branch"] { color: #f59e0b; }
|
|
1726
|
+
.step-label { font-size: 13px; color: var(--text); flex: 1; min-width: 0; }
|
|
1727
|
+
.step-type-badge { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: var(--bg-hover); color: var(--text-dim); font-weight: 500; text-transform: uppercase; letter-spacing: 0.3px; }
|
|
1728
|
+
.step-duration { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
|
1729
|
+
.step-status { font-size: 13px; font-weight: 700; }
|
|
1730
|
+
.step-status-ok { color: #22c55e; }
|
|
1731
|
+
.step-status-err { color: #ef4444; }
|
|
1732
|
+
.step-expand-icon, .step-card-header > svg[class*="step-expand"] { width: 12px; height: 12px; color: var(--text-dim); }
|
|
1733
|
+
.step-error { border-color: #ef4444; }
|
|
1734
|
+
.step-error-msg { font-size: 12px; color: #ef4444; margin-top: 6px; padding-left: 22px; }
|
|
1735
|
+
.step-detail { margin-top: 8px; padding: 8px 10px; background: var(--bg); border-radius: 6px; overflow-x: auto; }
|
|
1736
|
+
.step-detail pre { font-size: 12px; color: var(--text-muted); margin: 0; white-space: pre-wrap; word-break: break-word; font-family: 'SF Mono', Menlo, Monaco, monospace; }
|
|
1737
|
+
|
|
1738
|
+
.step-loop { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; border-left: 3px solid #3b82f6; }
|
|
1739
|
+
.step-loop-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
|
1740
|
+
.step-loop-body { padding: 12px 8px 4px 8px; }
|
|
1741
|
+
|
|
1742
|
+
.step-branch { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; border-left: 3px solid #f59e0b; }
|
|
1743
|
+
.step-branch-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
|
1744
|
+
.step-branch-path { padding: 12px 8px 4px 8px; }
|
|
1745
|
+
.step-branch-else { border-top: 1px solid var(--border); }
|
|
1746
|
+
.step-branch-after { border-top: 1px solid var(--border); }
|
|
1747
|
+
.step-branch-path-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin-bottom: 8px; padding-left: 20px; }
|
|
1748
|
+
|
|
1749
|
+
.step-description { font-size: 12px; color: var(--text-muted); padding: 2px 14px 8px 36px; line-height: 1.4; }
|
|
1750
|
+
.detail-description { font-size: 14px; color: var(--text-muted); margin-top: 6px; line-height: 1.5; }
|
|
1751
|
+
|
|
1752
|
+
.step-flow-section { margin-top: 28px; }
|
|
1753
|
+
.step-flow-section-header { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
|
1754
|
+
.step-flow-section-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
|
1755
|
+
.step-flow-parse-error { padding: 12px 16px; background: var(--bg-card); border: 1px solid var(--border); border-left: 3px solid #ef4444; border-radius: 8px; margin-top: 24px; color: var(--text-muted); font-size: 13px; }
|
|
1756
|
+
|
|
1757
|
+
.source-syncs-section { margin-top: 28px; }
|
|
1758
|
+
.source-sync-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; }
|
|
1759
|
+
.source-sync-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
|
1760
|
+
.source-sync-title { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
1761
|
+
.source-sync-desc { font-size: 12px; color: var(--text-muted); line-height: 1.3; }
|
|
1762
|
+
.source-sync-schedule { font-size: 12px; color: var(--text-dim); margin-left: auto; white-space: nowrap; }
|
|
1763
|
+
.source-sync-group { border-top: 1px solid var(--border); }
|
|
1764
|
+
.source-sync-group:first-child { border-top: none; }
|
|
1765
|
+
.source-sync-name { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; color: var(--text); margin-bottom: 4px; }
|
|
1766
|
+
.source-sync-name-icon { width: 14px; height: 14px; color: #3b82f6; }
|
|
1767
|
+
.source-sync-props { padding: 8px 14px; }
|
|
1768
|
+
.source-sync-props .prop-row { padding: 2px 0; }
|
|
1769
|
+
.source-tables { padding: 4px 14px 10px; }
|
|
1770
|
+
.source-tables-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin-bottom: 6px; }
|
|
1771
|
+
.source-table-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; flex-wrap: wrap; }
|
|
1772
|
+
a.source-table-name { color: var(--text); text-decoration: none; }
|
|
1773
|
+
.source-table-icon { width: 13px; height: 13px; color: var(--text-dim); }
|
|
1774
|
+
.source-table-name { font-size: 13px; font-weight: 500; color: var(--text); }
|
|
1775
|
+
.source-table-pk { font-size: 11px; color: var(--text-dim); background: var(--bg); padding: 1px 6px; border-radius: 4px; }
|
|
1776
|
+
.source-table-endpoint { font-size: 11px; color: var(--text-dim); font-family: 'SF Mono', Menlo, Monaco, monospace; }
|
|
1777
|
+
.source-table-desc { font-size: 12px; color: var(--text-muted); width: 100%; padding-left: 21px; }
|
|
1778
|
+
|
|
1779
|
+
.run-selector { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
|
1780
|
+
.run-selector select { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; padding: 6px 10px; outline: none; }
|
|
1781
|
+
.run-selector select:focus { border-color: var(--accent); }
|
|
1782
|
+
.run-summary { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--text-muted); }
|
|
1783
|
+
.run-summary-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
|
1784
|
+
.run-summary-complete { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
1785
|
+
.run-summary-errored { background: rgba(239,68,68,0.15); color: #ef4444; }
|
|
1786
|
+
|
|
1787
|
+
/* Usage page */
|
|
1788
|
+
:root { --usage-green: #22c55e; --usage-yellow: #f59e0b; --usage-red: #ef4444; }
|
|
1789
|
+
.usage-page { }
|
|
1790
|
+
.usage-wrap { max-width: 640px; margin: 0 auto; }
|
|
1791
|
+
.usage-back { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-dim); text-decoration: none; margin-bottom: 24px; }
|
|
1792
|
+
.usage-back:hover { color: var(--text); }
|
|
1793
|
+
.usage-back svg { width: 14px; height: 14px; }
|
|
1794
|
+
.usage-banner { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 20px; }
|
|
1795
|
+
.usage-banner svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|
1796
|
+
.usage-banner a { margin-left: auto; white-space: nowrap; font-weight: 500; }
|
|
1797
|
+
.usage-banner-yellow { background: rgba(245,158,11,0.12); color: #f59e0b; border: 1px solid rgba(245,158,11,0.25); }
|
|
1798
|
+
.usage-banner-yellow a { color: #f59e0b; }
|
|
1799
|
+
.usage-banner-red { background: rgba(239,68,68,0.12); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); }
|
|
1800
|
+
.usage-banner-red a { color: #ef4444; }
|
|
1801
|
+
|
|
1802
|
+
/* Workspace card */
|
|
1803
|
+
.ws-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; }
|
|
1804
|
+
.ws-card-static { cursor: default; }
|
|
1805
|
+
.ws-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
|
1806
|
+
.ws-card-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; flex-shrink: 0; }
|
|
1807
|
+
.ws-card-right-top { display: flex; align-items: center; gap: 8px; }
|
|
1808
|
+
.ws-name { font-size: 16px; font-weight: 600; color: var(--text); }
|
|
1809
|
+
.ws-meta { font-size: 13px; color: var(--text-dim); margin-top: 6px; line-height: 1.6; }
|
|
1810
|
+
.ws-meta-row { display: flex; align-items: center; gap: 4px; }
|
|
1811
|
+
.ws-badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; background: #1a2332; color: var(--accent); flex-shrink: 0; }
|
|
1812
|
+
.ws-auto-row { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--text-dim); align-items: flex-end; }
|
|
1813
|
+
.ws-auto-label { display: flex; align-items: center; gap: 4px; position: relative; }
|
|
1814
|
+
.ws-auto-info { display: inline-flex; cursor: help; color: var(--text-dim); transition: color 0.15s; }
|
|
1815
|
+
.ws-auto-info:hover { color: var(--text); }
|
|
1816
|
+
.ws-auto-info:hover .ws-auto-tip, .ws-auto-info:focus .ws-auto-tip { display: block; }
|
|
1817
|
+
.ws-auto-tip { display: none; position: absolute; top: calc(100% + 8px); right: 0; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; font-size: 12px; font-weight: 400; color: var(--text-muted); line-height: 1.5; width: 240px; white-space: normal; box-shadow: 0 8px 24px rgba(0,0,0,0.3); z-index: 10; }
|
|
1818
|
+
.ws-auto-value { font-size: 11px; color: var(--text-muted); padding: 2px 6px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; }
|
|
1819
|
+
.ws-card-bottom { display: flex; justify-content: flex-end; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
|
1820
|
+
.ws-card-manage { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-dim); text-decoration: none; }
|
|
1821
|
+
.ws-card-manage:hover { color: var(--accent); }
|
|
1822
|
+
|
|
1823
|
+
/* Period picker */
|
|
1824
|
+
.usage-period-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
|
1825
|
+
.usage-period-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
|
1826
|
+
.usage-period-select { padding: 4px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; font-family: inherit; cursor: pointer; }
|
|
1827
|
+
.usage-period-select:focus { border-color: var(--accent); outline: none; }
|
|
1828
|
+
|
|
1829
|
+
.usage-tier-badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
1830
|
+
.usage-tier-free, .usage-tier-starter, .usage-tier-pro, .usage-tier-business { background: #1a2332; color: var(--accent); }
|
|
1831
|
+
.usage-packs { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-muted); padding: 10px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 20px; }
|
|
1832
|
+
.usage-packs svg { width: 14px; height: 14px; }
|
|
1833
|
+
.usage-meters { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; }
|
|
1834
|
+
.usage-meter { padding: 14px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; }
|
|
1835
|
+
.usage-meter-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
1836
|
+
.usage-meter-label { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; color: var(--text); }
|
|
1837
|
+
.usage-meter-label svg { width: 14px; height: 14px; color: var(--text-muted); }
|
|
1838
|
+
.usage-meter-pct { font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
1839
|
+
.usage-bar-track { position: relative; height: 6px; background: var(--bg); border-radius: 3px; overflow: visible; margin-bottom: 8px; }
|
|
1840
|
+
.usage-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s ease; }
|
|
1841
|
+
.usage-bar-base-marker { position: absolute; top: -2px; width: 2px; height: 10px; background: var(--text-dim); border-radius: 1px; }
|
|
1842
|
+
.usage-meter-values { display: flex; align-items: baseline; gap: 4px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
1843
|
+
.usage-meter-values span:first-child { color: var(--text); font-weight: 500; }
|
|
1844
|
+
.usage-meter-limit { color: var(--text-dim); }
|
|
1845
|
+
.usage-ai-section { margin-bottom: 24px; }
|
|
1846
|
+
.usage-ai-toggle { background: none; border: none; color: var(--text-muted); font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 6px; padding: 8px 0; }
|
|
1847
|
+
.usage-ai-toggle:hover { color: var(--text); }
|
|
1848
|
+
.usage-ai-toggle svg { width: 14px; height: 14px; }
|
|
1849
|
+
.usage-ai-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
|
|
1850
|
+
.usage-ai-table th { text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); padding: 6px 12px; border-bottom: 1px solid var(--border); }
|
|
1851
|
+
.usage-ai-table td { padding: 6px 12px; color: var(--text-muted); border-bottom: 1px solid var(--border); }
|
|
1852
|
+
.usage-ai-table tr:last-child td { border-bottom: none; }
|
|
1853
|
+
.usage-bar-overage { height: 100%; border-radius: 0 3px 3px 0; position: absolute; top: 0; background: #c084fc; }
|
|
1854
|
+
.usage-footer { padding-top: 8px; border-top: 1px solid var(--border); }
|
|
1855
|
+
.usage-footer a { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-muted); }
|
|
1856
|
+
.usage-footer a:hover { color: var(--accent); }
|
|
1857
|
+
.usage-footer svg { width: 14px; height: 14px; }
|
|
1858
|
+
|
|
1859
|
+
/* Overage balance section */
|
|
1860
|
+
.overage-section { margin-top: 8px; margin-bottom: 24px; }
|
|
1861
|
+
.overage-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; gap: 12px; }
|
|
1862
|
+
.overage-header-left { display: flex; align-items: center; gap: 6px; }
|
|
1863
|
+
.overage-header-label { font-size: 14px; font-weight: 600; color: var(--text); }
|
|
1864
|
+
.overage-info { display: inline-flex; cursor: help; color: var(--text-dim); transition: color 0.15s; position: relative; }
|
|
1865
|
+
.overage-info:hover { color: var(--text); }
|
|
1866
|
+
.overage-info:hover .overage-tip, .overage-info:focus .overage-tip { display: block; }
|
|
1867
|
+
.overage-tip { display: none; position: absolute; top: calc(100% + 8px); left: 0; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; font-size: 12px; font-weight: 400; color: var(--text-muted); line-height: 1.5; width: 280px; white-space: normal; box-shadow: 0 8px 24px rgba(0,0,0,0.3); z-index: 10; }
|
|
1868
|
+
.overage-manage-link { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-muted); text-decoration: none; white-space: nowrap; }
|
|
1869
|
+
.overage-manage-link:hover { color: var(--accent); }
|
|
1870
|
+
.overage-meters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
|
1871
|
+
@media (max-width: 480px) { .overage-meters { grid-template-columns: repeat(2, 1fr); } }
|
|
1872
|
+
.overage-stat { padding: 12px 14px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; }
|
|
1873
|
+
.overage-stat-value { font-size: 16px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; margin-bottom: 2px; }
|
|
1874
|
+
.overage-stat-label { font-size: 11px; color: var(--text-dim); display: flex; align-items: center; gap: 4px; }
|
|
1875
|
+
|
|
1876
|
+
.explorer-bg {
|
|
1877
|
+
position: fixed;
|
|
1878
|
+
bottom: clamp(calc(-25% + 100px), calc(-10% - 150px) + (100vw - 1600px) * 0.015, calc(-10% - 150px));
|
|
1879
|
+
right: clamp(-50%, calc(-5% - 30px) + (100vw - 1600px) * 0.045, calc(-5% - 30px));
|
|
1880
|
+
pointer-events: none;
|
|
1881
|
+
z-index: 0;
|
|
1882
|
+
color: var(--accent);
|
|
1883
|
+
opacity: 0.04;
|
|
1884
|
+
}
|
|
1885
|
+
.explorer-bg svg {
|
|
1886
|
+
width: clamp(250px, 35vw - 60px, 500px);
|
|
1887
|
+
height: auto;
|
|
1888
|
+
}
|
|
1889
|
+
.explorer-bg #wisp-1, .explorer-bg #wisp-2, .explorer-bg #wisp-3 { opacity: 0; will-change: opacity; }
|
|
1890
|
+
@keyframes wisp-fade { 0%, 100% { opacity: 0; } 50% { opacity: 1; } }
|
|
1891
|
+
@keyframes refresh-pulse { 0% { outline: 2px solid var(--accent); outline-offset: -2px; opacity: 0.6; } 100% { outline: 2px solid transparent; outline-offset: 4px; opacity: 0; } }
|
|
1892
|
+
.refresh-flash { animation: refresh-pulse 0.6s ease-out; }
|
|
1893
|
+
.explorer-bg #wisp-2 { animation: wisp-fade 8s ease-in-out 2s infinite both; }
|
|
1894
|
+
.explorer-bg #wisp-1 { animation: wisp-fade 8s ease-in-out 4s infinite both; }
|
|
1895
|
+
.explorer-bg #wisp-3 { animation: wisp-fade 8s ease-in-out 6s infinite both; }
|
|
1896
|
+
.section-toggle { cursor: pointer; display: flex; align-items: center; gap: 6px; user-select: none; }
|
|
1897
|
+
.section-toggle:hover { color: var(--accent); }
|
|
1898
|
+
.toggle-icon { width: 14px; height: 14px; }
|
|
1899
|
+
.soul-content { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; font-size: 13px; line-height: 1.6; color: var(--text-muted); white-space: pre-wrap; overflow-x: auto; max-height: 400px; overflow-y: auto; }
|
|
1900
|
+
.skill-group { margin-bottom: 16px; }
|
|
1901
|
+
.skill-group-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin-bottom: 8px; font-weight: 600; }
|
|
1902
|
+
.skill-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); }
|
|
1903
|
+
.skill-name { font-weight: 600; color: var(--text); font-size: 13px; }
|
|
1904
|
+
.skill-desc { color: var(--text-muted); font-size: 12px; }
|
|
1905
|
+
.brain-stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
|
|
1906
|
+
.brain-stat { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; min-width: 80px; text-align: center; }
|
|
1907
|
+
.brain-stat-value { font-size: 20px; font-weight: 700; color: var(--text); display: block; }
|
|
1908
|
+
.brain-stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
1909
|
+
.brain-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
|
|
1910
|
+
.brain-tabs button { background: none; border: none; color: var(--text-muted); padding: 8px 16px; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
1911
|
+
.brain-tabs button:hover { color: var(--text); }
|
|
1912
|
+
.brain-tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
1913
|
+
.brain-list { display: flex; flex-direction: column; gap: 8px; }
|
|
1914
|
+
.brain-item { display: flex; align-items: baseline; gap: 10px; padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; font-size: 13px; flex-wrap: wrap; }
|
|
1915
|
+
.brain-item.unresolved { border-left: 3px solid #ef4444; }
|
|
1916
|
+
.brain-item.resolved { opacity: 0.6; }
|
|
1917
|
+
.brain-item-text { flex: 1; color: var(--text-muted); min-width: 200px; }
|
|
1918
|
+
.brain-item-date { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
|
|
1919
|
+
.brain-empty { padding: 40px; text-align: center; color: var(--text-muted); }
|
|
1920
|
+
.brain-empty-tab { padding: 20px; color: var(--text-dim); text-align: center; }
|
|
1921
|
+
.struggle-badge { font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
|
|
1922
|
+
.badge-knowledge_gap { background: #7c3aed22; color: #a78bfa; }
|
|
1923
|
+
.badge-edge_case { background: #f59e0b22; color: #fbbf24; }
|
|
1924
|
+
.badge-cap_hit { background: #ef444422; color: #f87171; }
|
|
1925
|
+
.badge-correction { background: #3b82f622; color: #60a5fa; }
|
|
1926
|
+
.badge-fallback { background: #6b728022; color: #9ca3af; }
|
|
1927
|
+
.entity-name { font-weight: 600; color: var(--text); }
|
|
1928
|
+
.entity-type { font-size: 10px; padding: 2px 8px; border-radius: 10px; background: var(--border); color: var(--text-dim); text-transform: uppercase; }
|
|
1929
|
+
.outcome-badge { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 11px; font-weight: 700; flex-shrink: 0; }
|
|
1930
|
+
.outcome-badge.effective { background: #22c55e22; color: #4ade80; }
|
|
1931
|
+
.outcome-badge.ineffective { background: #ef444422; color: #f87171; }
|
|
1932
|
+
.outcome-badge.unknown { background: #6b728022; color: #9ca3af; }
|
|
1933
|
+
.session-workflow { font-weight: 600; color: var(--accent); font-size: 12px; }
|
|
1934
|
+
.explorer { position: relative; z-index: 1; }
|
|
1935
|
+
|
|
1936
|
+
@media (max-width: 640px) {
|
|
1937
|
+
.explorer-main { padding: 12px 16px 48px; }
|
|
1938
|
+
.explorer-header { padding: 0 16px; height: 48px; }
|
|
1939
|
+
.card-list { grid-template-columns: 1fr; }
|
|
1940
|
+
.toolbar { flex-direction: column; align-items: stretch; }
|
|
1941
|
+
.search-input { width: 100%; }
|
|
1942
|
+
.filter-row { flex-wrap: wrap; }
|
|
1943
|
+
.data-table { font-size: 12px; }
|
|
1944
|
+
.data-table th, .data-table td { padding: 4px 8px; }
|
|
1945
|
+
}
|
|
1946
|
+
</style>
|
|
1947
|
+
</head>
|
|
1948
|
+
<body>
|
|
1949
|
+
<div class="explorer-bg" aria-hidden="true">
|
|
1950
|
+
<svg viewBox="0 0 291 371" fill="none"><path d="M134.129 122.279L140.915 122.8L141.155 124.478L141.394 126.157L151.534 126.674L161.674 127.191L167.245 128.392L172.817 129.593L176.743 130.899L180.668 132.206L179.403 132.915L178.139 133.624L170.306 134.807L162.472 135.99L156.284 135.995L150.097 136V138.406V140.813L103.591 140.606L57.0848 140.4L56.8325 138.2L56.5794 136L51.2438 135.984L45.9073 135.968L40.3186 135.187L34.7299 134.406L30.9376 133.523L27.1452 132.641V131.987V131.334L34.1311 129.714L41.117 128.094L47.362 127.247L53.6078 126.4H59.5374H65.4678V124.438V122.476L70.4577 122.192L75.4477 121.908L101.395 121.834L127.343 121.759L134.129 122.279Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.07464 136.078L5.58875 136.957V137.573V138.189L10.5787 139.553L15.5686 140.917L19.6268 142.398L23.6858 143.878L29.8645 144.739L36.0433 145.6H43.9161H51.7891L52.0422 147.8L52.2944 150H103.391H154.488L154.729 147.918L154.97 145.836L167.503 145.398L180.036 144.96L184.827 143.222L189.617 141.485L195.206 139.871L200.795 138.258L201.623 137.208L202.452 136.158L204.418 135.879L206.383 135.6L206.407 231.8L206.431 328H204.315H202.198L201.636 335.2L201.074 342.4H199.138H197.202V344.8V347.2H194.807H192.411V349.6V352H190.016H187.621V354.4V356.8H182.831H178.04V359.2V361.6H173.25H168.46L168.461 363.8L168.461 366H157.084H145.706L145.454 368.2L145.2 370.4H103.391H61.5821L61.329 368.2L61.0767 366L49.3005 365.778L37.5243 365.558V363.578V361.6H33.1331H28.742V359.2V356.8H23.9517H19.1613V354.453V352.106L16.9658 351.853L14.7702 351.6L14.5179 349.4L14.2648 347.2H11.9232H9.58069V342.4V337.6H7.18553H4.79036V332.8V328H2.3952H3.8147e-05V231.6V135.2H0.280282H0.560526L3.07464 136.078ZM19.1613 323.094V243.547V164H23.9517H28.742V239.2V314.4H31.1372H33.5323V321.6V328.8H35.5283H37.5243V330.8V332.8H39.9194H42.3146V335.2V337.6H44.7097H47.1049V342.4V347.2H42.7665H38.4288L38.1757 345L37.9235 342.8L33.4948 342.565L29.0654 342.33L28.1751 339.965L27.2841 337.6H25.649H24.0139L23.7832 330.6L23.5525 323.6L21.3569 323.347L19.1613 323.094Z" fill="currentColor"/><path d="M237.972 155.228L239.516 156.056V157.586V159.116L243.708 159.358L247.899 159.6L248.687 161.4L249.475 163.2H251.182H252.889L255.384 163.7L257.879 164.2V166.5V168.8H260.189H262.499L262.961 170.007L263.423 171.214L265.442 172.135L267.46 173.057V175.275V177.494L269.655 177.747L271.851 178L272.101 180.6L272.352 183.2H274.585H276.819L276.438 185.2L276.056 187.2H278.544H281.033V192V196.8H283.428H285.823V204V211.2H288.218H290.613V230V248.8H288.218H285.823V255.547V262.294L283.627 262.547L281.432 262.8L281.192 267.4L280.953 272H278.977H277.001L276.522 274.4L276.043 276.8H274.146H272.25V278.508V280.217L270.454 282.033L268.658 283.85L267.628 285.125L266.598 286.4H264.634H262.67V288.8V291.2H260.274H257.879V293.2V295.2H255.484H253.089V297.6V300H246.303H239.516V302.4V304.8H227.541H215.565V295.622V286.442L227.341 286.222L239.117 286L239.086 283.8L239.054 281.6H243.677H248.299V279.2V276.8H250.694H253.089V274.4V272H255.484H257.879V269.6V267.2H260.274H262.67V264.8V262.4H265.065H267.46V257.492V252.583L269.456 250.73L271.452 248.876V230.038V211.2H269.487H267.522L267.291 204.2L267.061 197.2L264.865 196.947L262.67 196.694V194.347V192H260.274H257.879V190.063V188.127L255.484 187.6L253.089 187.073V185.19V183.306L250.893 183.053L248.698 182.8L248.445 180.6L248.192 178.4H243.907H239.623L239.369 176.2L239.117 174L227.341 173.778L215.565 173.558V163.978V154.4H225.996H236.427L237.972 155.228Z" fill="currentColor"/><path id="wisp-1" d="M107.782 6.8V13.6H110.073H112.364L112.858 16.6L113.354 19.6V23.2V26.8L112.866 29.756L112.378 32.7128L110.28 32.956L108.182 33.2L107.929 35.4L107.676 37.6H105.786H103.897L103.644 39.8L103.391 42L101.196 42.2528L99.0001 42.5064V44.0792V45.6512L97.8433 48.4256L96.6864 51.2H95.5527H94.419L93.924 54.2L93.4289 57.2L93.4904 64.4L93.5511 71.6L94.0805 75.6L94.609 79.6L96.8045 79.8528L99.0001 80.1064V82.0528V84H103.391H107.782V79.6V75.2H105.88H103.976L103.495 73.4L103.014 71.6L103.003 65.852L102.992 60.104L103.77 58.052L104.549 56H106.113H107.676L107.929 53.8L108.182 51.6L110.328 51.3512L112.476 51.1032L112.724 48.9512L112.972 46.8L115.167 46.5472L117.363 46.2936V44.4V42.5064L119.559 42.2528L121.754 42L121.992 37.4496L122.23 32.8992L124.387 32.6496L126.544 32.4L126.303 26L126.062 19.6L123.583 12.2392L121.105 4.87841L119.433 4.6392L117.762 4.4L117.51 2.2L117.257 0H112.52H107.782V6.8Z" fill="currentColor"/><path id="wisp-2" d="M66.2662 37.548V47.096L65.4878 49.148L64.7093 51.2H63.0926H61.4759V53.6V56H59.0807H56.6856V58.4V60.8H54.2904H51.8952V69.9472V79.0936L54.0908 79.3472L56.2864 79.6L56.5386 81.8L56.7917 84H61.1294H65.4678V79.6V75.2H63.4719H61.4759V70.4V65.6H63.4088H65.3409L65.867 63.2L66.3932 60.8H68.2726H70.152L70.4051 58.6L70.6573 56.4L72.8042 56.1512L74.9519 55.9032L75.1994 53.7512L75.4477 51.6L77.6432 51.3472L79.8388 51.0936V41.9472V32.8H77.4436H75.0485V30.4V28H70.6573H66.2662V37.548Z" fill="currentColor"/><path id="wisp-3" d="M146.096 36.6L146.087 45.2L145.592 48.2L145.097 51.2H143.206H141.315V53.6V56H138.919H136.524V58.4V60.8H134.129H131.734V69.9368V79.0728L134.129 79.6L136.524 80.1272V82.0632V84H140.915H145.307V79.6V75.2H143.359H141.413L140.876 71.6096L140.338 68.02L140.801 66.8096L141.264 65.6H143.286H145.307V63.2V60.8H147.649H149.991L150.244 58.6L150.496 56.4L152.692 56.1472L154.887 55.8936V53.5472V51.2H156.883H158.879V42V32.8H156.883H154.887V30.4V28H150.496H146.105L146.096 36.6Z" fill="currentColor"/><path d="M145.307 105.2V107.2L147.901 107.222L150.496 107.243L161.274 107.678L172.053 108.113L173.051 108.431L174.049 108.75V110.261V111.772L178.719 112.262L183.39 112.75L189.697 114.747L196.004 116.744L199.797 119.154L203.589 121.565V123.164V124.763L201.78 125.951L199.97 127.139L196.544 128.35L193.118 129.562L190.538 126.981L187.958 124.4L182.8 122.609L177.641 120.818L167.262 118.471L156.883 116.125L149.698 115.261L142.512 114.396L132.217 113.598L121.922 112.8H104.641H87.362L73.0214 113.988L58.6815 115.176L53.0928 116.069L47.5041 116.962L35.7965 119.63L24.0882 122.298L21.0264 123.871L17.9638 125.444L16.0987 127.446L14.2345 129.449L12.0844 128.831L9.93437 128.214L6.51408 126.465L3.09379 124.716L3.3429 122.962L3.59278 121.209L7.73164 118.734L11.8705 116.26L17.2644 114.486L22.6575 112.711L27.637 112.246L32.6158 111.78L33.0892 109.89L33.5627 108H40.2108H46.8598L54.1675 107.48L61.4759 106.961V105.08V103.2H103.391H145.307V105.2Z" fill="currentColor"/></svg>
|
|
1951
|
+
</div>
|
|
1952
|
+
<div id="root"></div>
|
|
1953
|
+
<script type="module" src="/explorer/bundle.js"></script>
|
|
1954
|
+
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
1955
|
+
<script>
|
|
1956
|
+
(function() {
|
|
1957
|
+
var t;
|
|
1958
|
+
new MutationObserver(function() {
|
|
1959
|
+
clearTimeout(t);
|
|
1960
|
+
t = setTimeout(function() { if (window.lucide) lucide.createIcons(); }, 50);
|
|
1961
|
+
}).observe(document.getElementById('root'), { childList: true, subtree: true });
|
|
1962
|
+
})();
|
|
1963
|
+
</script>
|
|
1964
|
+
</body>
|
|
1965
|
+
</html>`;
|
|
1966
|
+
}
|
|
1967
|
+
async function handleExplorerApi(req, res, path, url, cwd, config, surfaceConfigs) {
|
|
1968
|
+
const jsonResponse = (data, status = 200) => {
|
|
1969
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-cache" });
|
|
1970
|
+
res.end(JSON.stringify(data));
|
|
1971
|
+
};
|
|
1972
|
+
if (path === "/explorer/api/overview") {
|
|
1973
|
+
const localDbs = getKnownDatabases(cwd);
|
|
1974
|
+
const databases = [];
|
|
1975
|
+
for (const name of localDbs) {
|
|
1976
|
+
const dbPath = join(cwd, "databases", `${name}.db`);
|
|
1977
|
+
let sizeBytes = 0;
|
|
1978
|
+
let tableCount = 0;
|
|
1979
|
+
try {
|
|
1980
|
+
sizeBytes = statSync(dbPath).size;
|
|
1981
|
+
const db = new Database(dbPath, { readonly: true });
|
|
1982
|
+
const tables = db.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").get();
|
|
1983
|
+
tableCount = tables.c;
|
|
1984
|
+
db.close();
|
|
1985
|
+
}
|
|
1986
|
+
catch { }
|
|
1987
|
+
databases.push({ name, sizeBytes, tableCount, remote: false, local: true });
|
|
1988
|
+
}
|
|
1989
|
+
const dbRemotePath = join(cwd, "databases", ".remote");
|
|
1990
|
+
if (existsSync(dbRemotePath)) {
|
|
1991
|
+
try {
|
|
1992
|
+
const manifest = JSON.parse(readFileSync(dbRemotePath, "utf-8"));
|
|
1993
|
+
for (const [name, info] of Object.entries(manifest.databases ?? {})) {
|
|
1994
|
+
const existing = databases.find((d) => d.name === name);
|
|
1995
|
+
const remoteTables = Object.keys(info.tables ?? {});
|
|
1996
|
+
if (existing) {
|
|
1997
|
+
existing.remote = true;
|
|
1998
|
+
existing.remoteTables = remoteTables;
|
|
1999
|
+
}
|
|
2000
|
+
else {
|
|
2001
|
+
databases.push({
|
|
2002
|
+
name,
|
|
2003
|
+
sizeBytes: Math.round((info.size_mb ?? 0) * 1024 * 1024),
|
|
2004
|
+
tableCount: remoteTables.length,
|
|
2005
|
+
remote: true,
|
|
2006
|
+
local: false,
|
|
2007
|
+
remoteTables,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
catch { }
|
|
2013
|
+
}
|
|
2014
|
+
const allSrcConfigs = config.sources ?? {};
|
|
2015
|
+
for (const [, srcCfg] of Object.entries(allSrcConfigs)) {
|
|
2016
|
+
for (const [syncName, sync] of Object.entries(srcCfg.syncs ?? {})) {
|
|
2017
|
+
const dbName = sync.database;
|
|
2018
|
+
if (!dbName)
|
|
2019
|
+
continue;
|
|
2020
|
+
const syncFile = join(cwd, `src/sources/${syncName}.ts`);
|
|
2021
|
+
let parsedTables = [];
|
|
2022
|
+
if (existsSync(syncFile)) {
|
|
2023
|
+
try {
|
|
2024
|
+
const parsed = parseSourceFile(readFileSync(syncFile, "utf-8"), syncName);
|
|
2025
|
+
if (parsed)
|
|
2026
|
+
parsedTables = parsed.tables.map((t) => t.name);
|
|
2027
|
+
}
|
|
2028
|
+
catch { }
|
|
2029
|
+
}
|
|
2030
|
+
const existing = databases.find((d) => d.name === dbName);
|
|
2031
|
+
if (existing) {
|
|
2032
|
+
for (const t of parsedTables) {
|
|
2033
|
+
if (!existing.remoteTables?.includes(t)) {
|
|
2034
|
+
existing.remoteTables = [...(existing.remoteTables ?? []), t];
|
|
2035
|
+
existing.tableCount = existing.remoteTables.length;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
else {
|
|
2040
|
+
databases.push({
|
|
2041
|
+
name: dbName,
|
|
2042
|
+
sizeBytes: 0,
|
|
2043
|
+
tableCount: parsedTables.length,
|
|
2044
|
+
remote: true,
|
|
2045
|
+
local: false,
|
|
2046
|
+
remoteTables: parsedTables,
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
const wfConfigs = config.workflows ?? {};
|
|
2052
|
+
const workflows = Object.entries(wfConfigs).map(([name, cfg]) => {
|
|
2053
|
+
let stepCount = 0;
|
|
2054
|
+
const sourceFile = cfg.file ?? `src/workflows/${name}.ts`;
|
|
2055
|
+
if (existsSync(join(cwd, sourceFile))) {
|
|
2056
|
+
try {
|
|
2057
|
+
const src = readFileSync(join(cwd, sourceFile), "utf-8");
|
|
2058
|
+
const tree = parseWorkflowSteps(src, name, sourceFile);
|
|
2059
|
+
stepCount = countSteps(tree.steps);
|
|
2060
|
+
}
|
|
2061
|
+
catch { }
|
|
2062
|
+
}
|
|
2063
|
+
return { name, schedule: cfg.schedule, webhook: cfg.webhook, stepCount };
|
|
2064
|
+
});
|
|
2065
|
+
const explorerSources = Object.entries(config.sources ?? {}).map(([name, cfg]) => ({
|
|
2066
|
+
name,
|
|
2067
|
+
baseUrl: cfg.baseUrl ?? "",
|
|
2068
|
+
authType: cfg.auth?.type ?? "none",
|
|
2069
|
+
syncs: Object.entries(cfg.syncs ?? {}).map(([syncName, sync]) => ({
|
|
2070
|
+
name: syncName,
|
|
2071
|
+
database: sync.database,
|
|
2072
|
+
schedule: sync.schedule,
|
|
2073
|
+
})),
|
|
2074
|
+
}));
|
|
2075
|
+
const surfaces = [...surfaceConfigs.entries()]
|
|
2076
|
+
.filter(([id]) => id !== "_home")
|
|
2077
|
+
.map(([id, cfg]) => ({
|
|
2078
|
+
name: id,
|
|
2079
|
+
type: cfg.type,
|
|
2080
|
+
accessMode: cfg.access
|
|
2081
|
+
? cfg.access.mode
|
|
2082
|
+
: "public",
|
|
2083
|
+
title: cfg.title,
|
|
2084
|
+
}));
|
|
2085
|
+
let files = [];
|
|
2086
|
+
const filesDir = join(cwd, "files");
|
|
2087
|
+
if (existsSync(filesDir)) {
|
|
2088
|
+
files = readdirSync(filesDir)
|
|
2089
|
+
.filter((f) => !f.startsWith("."))
|
|
2090
|
+
.map((f) => {
|
|
2091
|
+
const fp = join(filesDir, f);
|
|
2092
|
+
const ext = f.split(".").pop()?.toLowerCase() ?? "";
|
|
2093
|
+
let sizeBytes = 0;
|
|
2094
|
+
try {
|
|
2095
|
+
sizeBytes = statSync(fp).size;
|
|
2096
|
+
}
|
|
2097
|
+
catch { }
|
|
2098
|
+
return { name: f, sizeBytes, type: ext, remote: false, local: true };
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
const filesRemotePath = join(cwd, "files", ".remote");
|
|
2102
|
+
if (existsSync(filesRemotePath)) {
|
|
2103
|
+
try {
|
|
2104
|
+
const manifest = JSON.parse(readFileSync(filesRemotePath, "utf-8"));
|
|
2105
|
+
for (const [name, info] of Object.entries(manifest.files ?? {})) {
|
|
2106
|
+
const existing = files.find((f) => f.name === name);
|
|
2107
|
+
if (existing) {
|
|
2108
|
+
existing.remote = true;
|
|
2109
|
+
}
|
|
2110
|
+
else {
|
|
2111
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
2112
|
+
files.push({ name, sizeBytes: info.size_bytes ?? 0, type: ext, remote: true, local: false });
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
catch { }
|
|
2117
|
+
}
|
|
2118
|
+
const workspace = {
|
|
2119
|
+
name: config.name,
|
|
2120
|
+
accentColor: config.branding?.accentColor ?? undefined,
|
|
2121
|
+
timezone: config.settings?.timezone ?? undefined,
|
|
2122
|
+
};
|
|
2123
|
+
const agentsDir = join(cwd, "src", "agents");
|
|
2124
|
+
let agents = [];
|
|
2125
|
+
if (existsSync(agentsDir)) {
|
|
2126
|
+
const agentFolders = readdirSync(agentsDir).filter((f) => {
|
|
2127
|
+
if (f === "shared-skills")
|
|
2128
|
+
return false;
|
|
2129
|
+
const p = join(agentsDir, f);
|
|
2130
|
+
return statSync(p).isDirectory() && existsSync(join(p, "agent.json"));
|
|
2131
|
+
});
|
|
2132
|
+
for (const folder of agentFolders) {
|
|
2133
|
+
try {
|
|
2134
|
+
const config = JSON.parse(readFileSync(join(agentsDir, folder, "agent.json"), "utf-8"));
|
|
2135
|
+
const agentSkillsDir = join(agentsDir, folder, "skills");
|
|
2136
|
+
const skillNames = existsSync(agentSkillsDir) ? readdirSync(agentSkillsDir).filter((s) => existsSync(join(agentSkillsDir, s, "SKILL.md"))) : [];
|
|
2137
|
+
const sharedDir = join(agentsDir, "shared-skills");
|
|
2138
|
+
const sharedNames = existsSync(sharedDir) ? readdirSync(sharedDir).filter((s) => existsSync(join(sharedDir, s, "SKILL.md"))) : [];
|
|
2139
|
+
agents.push({
|
|
2140
|
+
name: config.name ?? folder,
|
|
2141
|
+
model: config.model,
|
|
2142
|
+
tools: config.tools,
|
|
2143
|
+
caps: config.caps,
|
|
2144
|
+
memory: config.memory,
|
|
2145
|
+
requireApproval: config.requireApproval,
|
|
2146
|
+
instructions: config.instructions ?? "soul.md",
|
|
2147
|
+
skills: skillNames,
|
|
2148
|
+
sharedSkills: sharedNames,
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
catch { }
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
return jsonResponse({ workspace, databases, workflows, sources: explorerSources, surfaces, agents, files });
|
|
2155
|
+
}
|
|
2156
|
+
const dbMatch = path.match(/^\/explorer\/api\/databases\/([^/]+)$/);
|
|
2157
|
+
if (dbMatch) {
|
|
2158
|
+
const dbName = decodeURIComponent(dbMatch[1]);
|
|
2159
|
+
const dbPath = join(cwd, "databases", `${dbName}.db`);
|
|
2160
|
+
if (!existsSync(dbPath)) {
|
|
2161
|
+
const remotePath = join(cwd, "databases", ".remote");
|
|
2162
|
+
if (existsSync(remotePath)) {
|
|
2163
|
+
try {
|
|
2164
|
+
const manifest = JSON.parse(readFileSync(remotePath, "utf-8"));
|
|
2165
|
+
const remoteDb = manifest.databases?.[dbName];
|
|
2166
|
+
if (remoteDb) {
|
|
2167
|
+
const tables = Object.entries(remoteDb.tables ?? {}).map(([tName, tInfo]) => ({
|
|
2168
|
+
name: tName,
|
|
2169
|
+
rowCount: tInfo.row_count ?? 0,
|
|
2170
|
+
columns: (tInfo.columns ?? []).map((c) => ({ name: c.name, type: c.type, notnull: false, pk: false })),
|
|
2171
|
+
}));
|
|
2172
|
+
return jsonResponse({ name: dbName, sizeBytes: Math.round((remoteDb.size_mb ?? 0) * 1024 * 1024), tables, remote: true, pullCommand: `mug pull databases/${dbName}` });
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
catch { }
|
|
2176
|
+
}
|
|
2177
|
+
const srcConfigs = config.sources ?? {};
|
|
2178
|
+
const sourceTables = [];
|
|
2179
|
+
for (const [, srcCfg] of Object.entries(srcConfigs)) {
|
|
2180
|
+
for (const [syncName, sync] of Object.entries(srcCfg.syncs ?? {})) {
|
|
2181
|
+
if (sync.database !== dbName)
|
|
2182
|
+
continue;
|
|
2183
|
+
const syncFile = join(cwd, `src/sources/${syncName}.ts`);
|
|
2184
|
+
if (!existsSync(syncFile))
|
|
2185
|
+
continue;
|
|
2186
|
+
try {
|
|
2187
|
+
const parsed = parseSourceFile(readFileSync(syncFile, "utf-8"), syncName);
|
|
2188
|
+
if (parsed) {
|
|
2189
|
+
for (const t of parsed.tables) {
|
|
2190
|
+
if (!sourceTables.find((st) => st.name === t.name)) {
|
|
2191
|
+
sourceTables.push({ name: t.name, rowCount: 0, columns: [] });
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
catch { }
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
if (sourceTables.length > 0) {
|
|
2200
|
+
return jsonResponse({ name: dbName, sizeBytes: 0, tables: sourceTables, remote: true, pullCommand: `mug pull databases/${dbName}` });
|
|
2201
|
+
}
|
|
2202
|
+
return jsonResponse({ error: "Database not found" }, 404);
|
|
2203
|
+
}
|
|
2204
|
+
const db = new Database(dbPath, { readonly: true });
|
|
2205
|
+
try {
|
|
2206
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map((t) => {
|
|
2207
|
+
const cols = db.prepare(`PRAGMA table_info("${t.name}")`).all();
|
|
2208
|
+
const count = db.prepare(`SELECT count(*) as c FROM "${t.name}"`).get().c;
|
|
2209
|
+
return {
|
|
2210
|
+
name: t.name,
|
|
2211
|
+
rowCount: count,
|
|
2212
|
+
columns: cols.map((c) => ({ name: c.name, type: c.type, notnull: !!c.notnull, pk: !!c.pk })),
|
|
2213
|
+
};
|
|
2214
|
+
});
|
|
2215
|
+
const sourcesList = [];
|
|
2216
|
+
for (const [srcName, srcCfg] of Object.entries(config.sources ?? {})) {
|
|
2217
|
+
for (const [, sync] of Object.entries(srcCfg.syncs ?? {})) {
|
|
2218
|
+
if (sync.database === dbName) {
|
|
2219
|
+
sourcesList.push(srcName);
|
|
2220
|
+
break;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const surfacesList = [];
|
|
2225
|
+
for (const [id, cfg] of surfaceConfigs) {
|
|
2226
|
+
if (id === "_home")
|
|
2227
|
+
continue;
|
|
2228
|
+
const json = JSON.stringify(cfg);
|
|
2229
|
+
if (json.includes(`"${dbName}"`))
|
|
2230
|
+
surfacesList.push(id);
|
|
2231
|
+
}
|
|
2232
|
+
return jsonResponse({ name: dbName, sizeBytes: statSync(dbPath).size, tables, sources: sourcesList, surfaces: surfacesList });
|
|
2233
|
+
}
|
|
2234
|
+
finally {
|
|
2235
|
+
db.close();
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
const tableMatch = path.match(/^\/explorer\/api\/databases\/([^/]+)\/([^/]+)$/);
|
|
2239
|
+
if (tableMatch) {
|
|
2240
|
+
const dbName = decodeURIComponent(tableMatch[1]);
|
|
2241
|
+
const tableName = decodeURIComponent(tableMatch[2]);
|
|
2242
|
+
const dbPath = join(cwd, "databases", `${dbName}.db`);
|
|
2243
|
+
if (!existsSync(dbPath)) {
|
|
2244
|
+
const remotePath = join(cwd, "databases", ".remote");
|
|
2245
|
+
if (existsSync(remotePath)) {
|
|
2246
|
+
try {
|
|
2247
|
+
const manifest = JSON.parse(readFileSync(remotePath, "utf-8"));
|
|
2248
|
+
const remoteTable = manifest.databases?.[dbName]?.tables?.[tableName];
|
|
2249
|
+
if (remoteTable) {
|
|
2250
|
+
return jsonResponse({
|
|
2251
|
+
database: dbName, table: tableName, remote: true, pullCommand: `mug pull databases/${dbName}`,
|
|
2252
|
+
columns: (remoteTable.columns ?? []).map((c) => ({ name: c.name, type: c.type, notnull: false, pk: false })),
|
|
2253
|
+
rows: [], totalRows: remoteTable.row_count ?? 0, page: 1, pageSize: 100,
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
catch { }
|
|
2258
|
+
}
|
|
2259
|
+
const srcConfigs = config.sources ?? {};
|
|
2260
|
+
for (const [, srcCfg] of Object.entries(srcConfigs)) {
|
|
2261
|
+
for (const [syncName, sync] of Object.entries(srcCfg.syncs ?? {})) {
|
|
2262
|
+
if (sync.database !== dbName)
|
|
2263
|
+
continue;
|
|
2264
|
+
const syncFile = join(cwd, `src/sources/${syncName}.ts`);
|
|
2265
|
+
if (!existsSync(syncFile))
|
|
2266
|
+
continue;
|
|
2267
|
+
try {
|
|
2268
|
+
const parsed = parseSourceFile(readFileSync(syncFile, "utf-8"), syncName);
|
|
2269
|
+
const t = parsed?.tables.find((pt) => pt.name === tableName);
|
|
2270
|
+
if (t) {
|
|
2271
|
+
return jsonResponse({
|
|
2272
|
+
database: dbName, table: tableName, remote: true, pullCommand: `mug pull databases/${dbName}`,
|
|
2273
|
+
columns: [], rows: [], totalRows: 0, page: 1, pageSize: 100,
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
catch { }
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
return jsonResponse({ error: "Database not found" }, 404);
|
|
2281
|
+
}
|
|
2282
|
+
const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10));
|
|
2283
|
+
const pageSize = Math.min(500, Math.max(1, parseInt(url.searchParams.get("pageSize") ?? "100", 10)));
|
|
2284
|
+
const offset = (page - 1) * pageSize;
|
|
2285
|
+
const db = new Database(dbPath, { readonly: true });
|
|
2286
|
+
try {
|
|
2287
|
+
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(tableName);
|
|
2288
|
+
if (!tableExists)
|
|
2289
|
+
return jsonResponse({ error: "Table not found" }, 404);
|
|
2290
|
+
const columns = db.prepare(`PRAGMA table_info("${tableName}")`).all().map((c) => ({
|
|
2291
|
+
name: c.name,
|
|
2292
|
+
type: c.type,
|
|
2293
|
+
notnull: !!c.notnull,
|
|
2294
|
+
pk: !!c.pk,
|
|
2295
|
+
}));
|
|
2296
|
+
const columnNames = new Set(columns.map((c) => c.name));
|
|
2297
|
+
const whereClauses = [];
|
|
2298
|
+
const whereParams = [];
|
|
2299
|
+
const search = url.searchParams.get("search")?.trim();
|
|
2300
|
+
if (search) {
|
|
2301
|
+
const textCols = columns.filter((c) => {
|
|
2302
|
+
const t = c.type.toUpperCase();
|
|
2303
|
+
return !t.includes("INT") && !t.includes("REAL") && !t.includes("FLOAT") && !t.includes("NUMERIC") && !t.includes("BLOB");
|
|
2304
|
+
});
|
|
2305
|
+
if (textCols.length > 0) {
|
|
2306
|
+
const searchOr = textCols.map((c) => `"${c.name}" LIKE ?`).join(" OR ");
|
|
2307
|
+
whereClauses.push(`(${searchOr})`);
|
|
2308
|
+
for (const _ of textCols)
|
|
2309
|
+
whereParams.push(`%${search}%`);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
const filtersRaw = url.searchParams.get("filters");
|
|
2313
|
+
if (filtersRaw) {
|
|
2314
|
+
try {
|
|
2315
|
+
const filters = JSON.parse(filtersRaw);
|
|
2316
|
+
for (const f of filters) {
|
|
2317
|
+
if (!columnNames.has(f.column))
|
|
2318
|
+
continue;
|
|
2319
|
+
const col = `"${f.column}"`;
|
|
2320
|
+
switch (f.op) {
|
|
2321
|
+
case "eq":
|
|
2322
|
+
whereClauses.push(`${col} = ?`);
|
|
2323
|
+
whereParams.push(f.value ?? "");
|
|
2324
|
+
break;
|
|
2325
|
+
case "neq":
|
|
2326
|
+
whereClauses.push(`${col} != ?`);
|
|
2327
|
+
whereParams.push(f.value ?? "");
|
|
2328
|
+
break;
|
|
2329
|
+
case "contains":
|
|
2330
|
+
whereClauses.push(`${col} LIKE ?`);
|
|
2331
|
+
whereParams.push(`%${f.value ?? ""}%`);
|
|
2332
|
+
break;
|
|
2333
|
+
case "starts_with":
|
|
2334
|
+
whereClauses.push(`${col} LIKE ?`);
|
|
2335
|
+
whereParams.push(`${f.value ?? ""}%`);
|
|
2336
|
+
break;
|
|
2337
|
+
case "gt":
|
|
2338
|
+
whereClauses.push(`${col} > ?`);
|
|
2339
|
+
whereParams.push(f.value ?? 0);
|
|
2340
|
+
break;
|
|
2341
|
+
case "gte":
|
|
2342
|
+
whereClauses.push(`${col} >= ?`);
|
|
2343
|
+
whereParams.push(f.value ?? 0);
|
|
2344
|
+
break;
|
|
2345
|
+
case "lt":
|
|
2346
|
+
whereClauses.push(`${col} < ?`);
|
|
2347
|
+
whereParams.push(f.value ?? 0);
|
|
2348
|
+
break;
|
|
2349
|
+
case "lte":
|
|
2350
|
+
whereClauses.push(`${col} <= ?`);
|
|
2351
|
+
whereParams.push(f.value ?? 0);
|
|
2352
|
+
break;
|
|
2353
|
+
case "empty":
|
|
2354
|
+
whereClauses.push(`(${col} IS NULL OR ${col} = '')`);
|
|
2355
|
+
break;
|
|
2356
|
+
case "not_empty":
|
|
2357
|
+
whereClauses.push(`(${col} IS NOT NULL AND ${col} != '')`);
|
|
2358
|
+
break;
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
catch { }
|
|
2363
|
+
}
|
|
2364
|
+
const whereSQL = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(" AND ")}` : "";
|
|
2365
|
+
let orderSQL = "";
|
|
2366
|
+
const sortRaw = url.searchParams.get("sort");
|
|
2367
|
+
if (sortRaw) {
|
|
2368
|
+
try {
|
|
2369
|
+
const sorts = JSON.parse(sortRaw);
|
|
2370
|
+
const validSorts = sorts.filter((s) => columnNames.has(s.column) && (s.dir === "asc" || s.dir === "desc"));
|
|
2371
|
+
if (validSorts.length > 0) {
|
|
2372
|
+
orderSQL = ` ORDER BY ${validSorts.map((s) => `"${s.column}" ${s.dir}`).join(", ")}`;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
catch { }
|
|
2376
|
+
}
|
|
2377
|
+
const countRow = db.prepare(`SELECT count(*) as c FROM "${tableName}"${whereSQL}`).get(...whereParams);
|
|
2378
|
+
const totalRows = countRow.c;
|
|
2379
|
+
const rows = db.prepare(`SELECT * FROM "${tableName}"${whereSQL}${orderSQL} LIMIT ? OFFSET ?`).all(...whereParams, pageSize, offset);
|
|
2380
|
+
return jsonResponse({ database: dbName, table: tableName, columns, rows, totalRows, page, pageSize });
|
|
2381
|
+
}
|
|
2382
|
+
finally {
|
|
2383
|
+
db.close();
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
const wfRunDetailMatch = path.match(/^\/explorer\/api\/workflows\/([^/]+)\/runs\/([^/]+)$/);
|
|
2387
|
+
if (wfRunDetailMatch) {
|
|
2388
|
+
const wfName = decodeURIComponent(wfRunDetailMatch[1]);
|
|
2389
|
+
const runId = decodeURIComponent(wfRunDetailMatch[2]);
|
|
2390
|
+
const opsDbPath = join(cwd, "databases", "_mug_ops.db");
|
|
2391
|
+
if (!existsSync(opsDbPath))
|
|
2392
|
+
return jsonResponse({ error: "No run history" }, 404);
|
|
2393
|
+
try {
|
|
2394
|
+
const db = new Database(opsDbPath, { readonly: true });
|
|
2395
|
+
const run = db.prepare("SELECT id, workflow, status, started_at, completed_at, duration_ms, result, error FROM workflow_runs WHERE id = ? AND workflow = ?").get(runId, wfName);
|
|
2396
|
+
if (!run) {
|
|
2397
|
+
db.close();
|
|
2398
|
+
return jsonResponse({ error: "Run not found" }, 404);
|
|
2399
|
+
}
|
|
2400
|
+
const steps = db.prepare("SELECT step_name, step_type, started_at, completed_at, duration_ms, input, output, error, tokens_used FROM workflow_steps WHERE run_id = ? ORDER BY id").all(runId);
|
|
2401
|
+
db.close();
|
|
2402
|
+
return jsonResponse({ run: { id: run.id, workflow: run.workflow, status: run.status, startedAt: run.started_at, completedAt: run.completed_at, durationMs: run.duration_ms, result: run.result ? JSON.parse(run.result) : null, error: run.error }, steps: steps.map(s => ({ name: s.step_name, type: s.step_type, startedAt: s.started_at, completedAt: s.completed_at, durationMs: s.duration_ms, input: s.input, output: s.output, error: s.error, tokensUsed: s.tokens_used })) });
|
|
2403
|
+
}
|
|
2404
|
+
catch {
|
|
2405
|
+
return jsonResponse({ error: "Failed to read run data" }, 500);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
const wfRunsMatch = path.match(/^\/explorer\/api\/workflows\/([^/]+)\/runs$/);
|
|
2409
|
+
if (wfRunsMatch) {
|
|
2410
|
+
const wfName = decodeURIComponent(wfRunsMatch[1]);
|
|
2411
|
+
const opsDbPath = join(cwd, "databases", "_mug_ops.db");
|
|
2412
|
+
if (!existsSync(opsDbPath))
|
|
2413
|
+
return jsonResponse({ runs: [] });
|
|
2414
|
+
try {
|
|
2415
|
+
const db = new Database(opsDbPath, { readonly: true });
|
|
2416
|
+
const runs = db.prepare("SELECT id, status, started_at, completed_at, duration_ms, error FROM workflow_runs WHERE workflow = ? ORDER BY started_at DESC LIMIT 20").all(wfName);
|
|
2417
|
+
db.close();
|
|
2418
|
+
return jsonResponse({ runs: runs.map(r => ({ id: r.id, status: r.status, startedAt: r.started_at, completedAt: r.completed_at, durationMs: r.duration_ms, error: r.error })) });
|
|
2419
|
+
}
|
|
2420
|
+
catch {
|
|
2421
|
+
return jsonResponse({ runs: [] });
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
const wfMatch = path.match(/^\/explorer\/api\/workflows\/([^/]+)$/);
|
|
2425
|
+
if (wfMatch) {
|
|
2426
|
+
const wfName = decodeURIComponent(wfMatch[1]);
|
|
2427
|
+
const workflows = config.workflows ?? {};
|
|
2428
|
+
const wfConfig = workflows[wfName];
|
|
2429
|
+
if (!wfConfig)
|
|
2430
|
+
return jsonResponse({ error: "Workflow not found" }, 404);
|
|
2431
|
+
const sourceFile = wfConfig.file ?? `src/workflows/${wfName}.ts`;
|
|
2432
|
+
const sourceExists = existsSync(join(cwd, sourceFile));
|
|
2433
|
+
let databases = [];
|
|
2434
|
+
let steps = null;
|
|
2435
|
+
let description;
|
|
2436
|
+
let parseError;
|
|
2437
|
+
if (sourceExists) {
|
|
2438
|
+
try {
|
|
2439
|
+
const src = readFileSync(join(cwd, sourceFile), "utf-8");
|
|
2440
|
+
const queryMatches = src.matchAll(/ctx\.(?:query|exec)\s*\(\s*['"]([^'"]+)['"]/g);
|
|
2441
|
+
databases = [...new Set([...queryMatches].map((m) => m[1]))];
|
|
2442
|
+
const tree = parseWorkflowSteps(src, wfName, sourceFile);
|
|
2443
|
+
steps = tree.steps;
|
|
2444
|
+
description = tree.description;
|
|
2445
|
+
}
|
|
2446
|
+
catch (e) {
|
|
2447
|
+
parseError = e.message;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
const allSources = config.sources ?? {};
|
|
2451
|
+
const sourcesList = [];
|
|
2452
|
+
for (const [srcName, srcCfg] of Object.entries(allSources)) {
|
|
2453
|
+
const syncDbs = Object.values(srcCfg.syncs ?? {}).map((s) => s.database).filter(Boolean);
|
|
2454
|
+
if (databases.some((db) => syncDbs.includes(db))) {
|
|
2455
|
+
sourcesList.push(srcName);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
const surfacesList = [];
|
|
2459
|
+
for (const [id, cfg] of surfaceConfigs) {
|
|
2460
|
+
if (id === "_home")
|
|
2461
|
+
continue;
|
|
2462
|
+
const json = JSON.stringify(cfg);
|
|
2463
|
+
if (json.includes(wfName))
|
|
2464
|
+
surfacesList.push(id);
|
|
2465
|
+
}
|
|
2466
|
+
return jsonResponse({ name: wfName, description, schedule: wfConfig.schedule, webhook: wfConfig.webhook, sourceFile, sourceExists, databases, sources: sourcesList, surfaces: surfacesList, steps, parseError });
|
|
2467
|
+
}
|
|
2468
|
+
const sourceMatch = path.match(/^\/explorer\/api\/sources\/([^/]+)$/);
|
|
2469
|
+
if (sourceMatch) {
|
|
2470
|
+
const srcName = decodeURIComponent(sourceMatch[1]);
|
|
2471
|
+
const allSources = config.sources ?? {};
|
|
2472
|
+
const srcConfig = allSources[srcName];
|
|
2473
|
+
if (!srcConfig)
|
|
2474
|
+
return jsonResponse({ error: "Source not found" }, 404);
|
|
2475
|
+
const syncs = Object.entries(srcConfig.syncs ?? {}).map(([syncName, sync]) => {
|
|
2476
|
+
const syncFile = `src/sources/${syncName}.ts`;
|
|
2477
|
+
const syncExists = existsSync(join(cwd, syncFile));
|
|
2478
|
+
let parsed = null;
|
|
2479
|
+
let parseError;
|
|
2480
|
+
if (syncExists) {
|
|
2481
|
+
try {
|
|
2482
|
+
const src = readFileSync(join(cwd, syncFile), "utf-8");
|
|
2483
|
+
parsed = parseSourceFile(src, syncFile);
|
|
2484
|
+
}
|
|
2485
|
+
catch (e) {
|
|
2486
|
+
parseError = e.message;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
return {
|
|
2490
|
+
name: syncName,
|
|
2491
|
+
database: sync.database,
|
|
2492
|
+
schedule: sync.schedule,
|
|
2493
|
+
sourceFile: syncFile,
|
|
2494
|
+
sourceExists: syncExists,
|
|
2495
|
+
description: parsed?.description,
|
|
2496
|
+
tables: parsed?.tables ?? [],
|
|
2497
|
+
parseError,
|
|
2498
|
+
};
|
|
2499
|
+
});
|
|
2500
|
+
const databases = [...new Set(syncs.map((s) => s.database).filter(Boolean))];
|
|
2501
|
+
return jsonResponse({ name: srcName, baseUrl: srcConfig.baseUrl ?? "", authType: srcConfig.auth?.type ?? "none", syncs, databases });
|
|
2502
|
+
}
|
|
2503
|
+
const surfMatch = path.match(/^\/explorer\/api\/surfaces\/([^/]+)$/);
|
|
2504
|
+
if (surfMatch) {
|
|
2505
|
+
const surfName = decodeURIComponent(surfMatch[1]);
|
|
2506
|
+
const surfConfig = surfaceConfigs.get(surfName);
|
|
2507
|
+
if (!surfConfig)
|
|
2508
|
+
return jsonResponse({ error: "Surface not found" }, 404);
|
|
2509
|
+
const surfJson = JSON.stringify(surfConfig);
|
|
2510
|
+
const dbNames = getKnownDatabases(cwd);
|
|
2511
|
+
const databases = dbNames.filter((d) => surfJson.includes(`"${d}"`));
|
|
2512
|
+
const allWorkflows = Object.keys(config.workflows ?? {});
|
|
2513
|
+
const workflows = allWorkflows.filter((w) => surfJson.includes(w));
|
|
2514
|
+
const accessMode = surfConfig.access
|
|
2515
|
+
? surfConfig.access.mode ?? "public"
|
|
2516
|
+
: "public";
|
|
2517
|
+
return jsonResponse({
|
|
2518
|
+
name: surfName,
|
|
2519
|
+
title: surfConfig.title,
|
|
2520
|
+
type: surfConfig.type ?? "form",
|
|
2521
|
+
accessMode,
|
|
2522
|
+
databases,
|
|
2523
|
+
workflows,
|
|
2524
|
+
liveUrl: `/${surfName}`,
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
const agentMatch = path.match(/^\/explorer\/api\/agents\/([^/]+)$/);
|
|
2528
|
+
if (agentMatch) {
|
|
2529
|
+
const agentName = decodeURIComponent(agentMatch[1]);
|
|
2530
|
+
const agentDir = join(cwd, "src", "agents", agentName);
|
|
2531
|
+
const configPath = join(agentDir, "agent.json");
|
|
2532
|
+
const sourceExists = existsSync(configPath);
|
|
2533
|
+
let agentConfig = {};
|
|
2534
|
+
let soulContent;
|
|
2535
|
+
let skills = [];
|
|
2536
|
+
let sharedSkills = [];
|
|
2537
|
+
if (sourceExists) {
|
|
2538
|
+
try {
|
|
2539
|
+
agentConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
2540
|
+
}
|
|
2541
|
+
catch { }
|
|
2542
|
+
const soulPath = join(agentDir, agentConfig.instructions ?? "soul.md");
|
|
2543
|
+
if (existsSync(soulPath)) {
|
|
2544
|
+
soulContent = readFileSync(soulPath, "utf-8");
|
|
2545
|
+
}
|
|
2546
|
+
const skillsDir = join(agentDir, "skills");
|
|
2547
|
+
if (existsSync(skillsDir)) {
|
|
2548
|
+
for (const s of readdirSync(skillsDir)) {
|
|
2549
|
+
const skillFile = join(skillsDir, s, "SKILL.md");
|
|
2550
|
+
if (existsSync(skillFile)) {
|
|
2551
|
+
const content = readFileSync(skillFile, "utf-8");
|
|
2552
|
+
const descMatch = content.match(/^description:\s*(.+)$/m);
|
|
2553
|
+
skills.push({ name: s, description: descMatch?.[1] });
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
const sharedDir = join(cwd, "src", "agents", "shared-skills");
|
|
2558
|
+
if (existsSync(sharedDir)) {
|
|
2559
|
+
for (const s of readdirSync(sharedDir)) {
|
|
2560
|
+
const skillFile = join(sharedDir, s, "SKILL.md");
|
|
2561
|
+
if (existsSync(skillFile)) {
|
|
2562
|
+
const content = readFileSync(skillFile, "utf-8");
|
|
2563
|
+
const descMatch = content.match(/^description:\s*(.+)$/m);
|
|
2564
|
+
sharedSkills.push({ name: s, description: descMatch?.[1] });
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
const allWorkflows = Object.keys(config.workflows ?? {});
|
|
2570
|
+
const workflows = [];
|
|
2571
|
+
for (const wfName of allWorkflows) {
|
|
2572
|
+
const wfFile = join(cwd, "src", "workflows", `${wfName}.ts`);
|
|
2573
|
+
if (existsSync(wfFile)) {
|
|
2574
|
+
try {
|
|
2575
|
+
const src = readFileSync(wfFile, "utf-8");
|
|
2576
|
+
if (src.includes(`"${agentName}"`) || src.includes(`'${agentName}'`))
|
|
2577
|
+
workflows.push(wfName);
|
|
2578
|
+
}
|
|
2579
|
+
catch { }
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
return jsonResponse({
|
|
2583
|
+
name: agentConfig.name ?? agentName,
|
|
2584
|
+
model: agentConfig.model ?? "default",
|
|
2585
|
+
tools: agentConfig.tools ?? [],
|
|
2586
|
+
caps: agentConfig.caps ?? {},
|
|
2587
|
+
memory: agentConfig.memory ?? {},
|
|
2588
|
+
requireApproval: agentConfig.requireApproval ?? [],
|
|
2589
|
+
sourceFile: `src/agents/${agentName}/agent.json`,
|
|
2590
|
+
sourceExists,
|
|
2591
|
+
instructionFile: agentConfig.instructions ?? "soul.md",
|
|
2592
|
+
soulContent,
|
|
2593
|
+
skills,
|
|
2594
|
+
sharedSkills,
|
|
2595
|
+
workflows,
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
const agentBrainMatch = path.match(/^\/explorer\/api\/agents\/([^/]+)\/brain$/);
|
|
2599
|
+
if (agentBrainMatch) {
|
|
2600
|
+
const agentName = decodeURIComponent(agentBrainMatch[1]);
|
|
2601
|
+
const brainPath = join(cwd, "src", "agents", agentName, "brain.db");
|
|
2602
|
+
if (!existsSync(brainPath)) {
|
|
2603
|
+
return jsonResponse({
|
|
2604
|
+
entityCount: 0, factCount: 0, outcomeCount: 0, struggleCount: 0, sessionCount: 0,
|
|
2605
|
+
entities: [], recentStruggles: [], recentOutcomes: [], recentSessions: [],
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
try {
|
|
2609
|
+
const brainDb = new Database(brainPath, { readonly: true });
|
|
2610
|
+
const tables = brainDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
|
2611
|
+
const hasSchema = tables.includes("brain_entities");
|
|
2612
|
+
if (!hasSchema) {
|
|
2613
|
+
brainDb.close();
|
|
2614
|
+
return jsonResponse({
|
|
2615
|
+
entityCount: 0, factCount: 0, outcomeCount: 0, struggleCount: 0, sessionCount: 0,
|
|
2616
|
+
entities: [], recentStruggles: [], recentOutcomes: [], recentSessions: [],
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
const entityCount = brainDb.prepare("SELECT COUNT(*) as c FROM brain_entities").get().c;
|
|
2620
|
+
const factCount = brainDb.prepare("SELECT COUNT(*) as c FROM brain_facts").get().c;
|
|
2621
|
+
const outcomeCount = brainDb.prepare("SELECT COUNT(*) as c FROM brain_outcomes").get().c;
|
|
2622
|
+
const struggleCount = brainDb.prepare("SELECT COUNT(*) as c FROM brain_struggles").get().c;
|
|
2623
|
+
const sessionCount = brainDb.prepare("SELECT COUNT(*) as c FROM brain_sessions").get().c;
|
|
2624
|
+
const entities = brainDb.prepare("SELECT name, type, summary, mention_count FROM brain_entities ORDER BY mention_count DESC LIMIT 50").all();
|
|
2625
|
+
const recentStruggles = brainDb.prepare("SELECT category, description, resolved, recorded_at FROM brain_struggles ORDER BY recorded_at DESC LIMIT 20").all();
|
|
2626
|
+
const recentOutcomes = brainDb.prepare("SELECT action, result, effective, recorded_at FROM brain_outcomes ORDER BY recorded_at DESC LIMIT 20").all();
|
|
2627
|
+
const recentSessions = brainDb.prepare("SELECT id, workflow, goal, started_at, credits_used, turns_used, capped FROM brain_sessions ORDER BY started_at DESC LIMIT 20").all();
|
|
2628
|
+
brainDb.close();
|
|
2629
|
+
return jsonResponse({ entityCount, factCount, outcomeCount, struggleCount, sessionCount, entities, recentStruggles, recentOutcomes, recentSessions });
|
|
2630
|
+
}
|
|
2631
|
+
catch {
|
|
2632
|
+
return jsonResponse({
|
|
2633
|
+
entityCount: 0, factCount: 0, outcomeCount: 0, struggleCount: 0, sessionCount: 0,
|
|
2634
|
+
entities: [], recentStruggles: [], recentOutcomes: [], recentSessions: [],
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
const agentSessionDetailMatch = path.match(/^\/explorer\/api\/agents\/([^/]+)\/sessions\/(.+)$/);
|
|
2639
|
+
if (agentSessionDetailMatch) {
|
|
2640
|
+
const agentName = decodeURIComponent(agentSessionDetailMatch[1]);
|
|
2641
|
+
const runId = decodeURIComponent(agentSessionDetailMatch[2]);
|
|
2642
|
+
const opsDbPath = join(cwd, "databases", "_mug_ops.db");
|
|
2643
|
+
if (!existsSync(opsDbPath))
|
|
2644
|
+
return jsonResponse({ error: "No run history" }, 404);
|
|
2645
|
+
try {
|
|
2646
|
+
const db = new Database(opsDbPath, { readonly: true });
|
|
2647
|
+
const step = db.prepare("SELECT ws.run_id, ws.step_name, ws.step_type, ws.started_at, ws.completed_at, ws.duration_ms, ws.input, ws.output, ws.error, ws.tokens_used, wr.workflow FROM workflow_steps ws JOIN workflow_runs wr ON ws.run_id = wr.id WHERE ws.run_id = ? AND ws.step_type = 'agent' AND ws.step_name LIKE ? ORDER BY ws.id LIMIT 1").get(runId, `%${agentName}%`);
|
|
2648
|
+
if (!step) {
|
|
2649
|
+
db.close();
|
|
2650
|
+
return jsonResponse({ error: "Session not found" }, 404);
|
|
2651
|
+
}
|
|
2652
|
+
let goal = "";
|
|
2653
|
+
let response = "";
|
|
2654
|
+
try {
|
|
2655
|
+
const inp = JSON.parse(step.input ?? "{}");
|
|
2656
|
+
goal = inp.goal ?? "";
|
|
2657
|
+
}
|
|
2658
|
+
catch { }
|
|
2659
|
+
try {
|
|
2660
|
+
response = step.output ?? "";
|
|
2661
|
+
}
|
|
2662
|
+
catch { }
|
|
2663
|
+
const allSteps = db.prepare("SELECT step_name, step_type, started_at, duration_ms, output, error, tokens_used FROM workflow_steps WHERE run_id = ? ORDER BY id").all(runId);
|
|
2664
|
+
db.close();
|
|
2665
|
+
return jsonResponse({
|
|
2666
|
+
session: {
|
|
2667
|
+
runId: step.run_id, workflow: step.workflow, stepName: step.step_name,
|
|
2668
|
+
startedAt: step.started_at, completedAt: step.completed_at,
|
|
2669
|
+
durationMs: step.duration_ms, goal, response,
|
|
2670
|
+
tokensUsed: step.tokens_used, error: step.error,
|
|
2671
|
+
},
|
|
2672
|
+
allSteps: allSteps.map((s) => ({
|
|
2673
|
+
name: s.step_name, type: s.step_type, startedAt: s.started_at,
|
|
2674
|
+
durationMs: s.duration_ms, output: s.output, error: s.error, tokensUsed: s.tokens_used,
|
|
2675
|
+
})),
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
catch {
|
|
2679
|
+
return jsonResponse({ error: "Failed to query runs" }, 500);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
const agentSessionsMatch = path.match(/^\/explorer\/api\/agents\/([^/]+)\/sessions$/);
|
|
2683
|
+
if (agentSessionsMatch) {
|
|
2684
|
+
const agentName = decodeURIComponent(agentSessionsMatch[1]);
|
|
2685
|
+
const opsDbPath = join(cwd, "databases", "_mug_ops.db");
|
|
2686
|
+
if (!existsSync(opsDbPath))
|
|
2687
|
+
return jsonResponse({ sessions: [] });
|
|
2688
|
+
try {
|
|
2689
|
+
const db = new Database(opsDbPath, { readonly: true });
|
|
2690
|
+
const rows = db.prepare("SELECT ws.run_id, ws.step_name, ws.started_at, ws.completed_at, ws.duration_ms, ws.input, ws.output, ws.error, ws.tokens_used, wr.workflow FROM workflow_steps ws JOIN workflow_runs wr ON ws.run_id = wr.id WHERE ws.step_type = 'agent' AND ws.step_name LIKE ? ORDER BY ws.started_at DESC LIMIT 50").all(`%${agentName}%`);
|
|
2691
|
+
db.close();
|
|
2692
|
+
const sessions = rows.map((r) => {
|
|
2693
|
+
let goal = "";
|
|
2694
|
+
try {
|
|
2695
|
+
const inp = JSON.parse(r.input ?? "{}");
|
|
2696
|
+
goal = inp.goal ?? "";
|
|
2697
|
+
}
|
|
2698
|
+
catch { }
|
|
2699
|
+
return {
|
|
2700
|
+
runId: r.run_id, workflow: r.workflow, stepName: r.step_name,
|
|
2701
|
+
startedAt: r.started_at, completedAt: r.completed_at,
|
|
2702
|
+
durationMs: r.duration_ms, goal,
|
|
2703
|
+
response: (r.output ?? "").slice(0, 200),
|
|
2704
|
+
tokensUsed: r.tokens_used, error: r.error,
|
|
2705
|
+
};
|
|
2706
|
+
});
|
|
2707
|
+
return jsonResponse({ sessions });
|
|
2708
|
+
}
|
|
2709
|
+
catch {
|
|
2710
|
+
return jsonResponse({ sessions: [] });
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
const changesetDetailMatch = path.match(/^\/explorer\/api\/changesets\/([^/]+)\/([^/]+)$/);
|
|
2714
|
+
if (changesetDetailMatch) {
|
|
2715
|
+
const database = decodeURIComponent(changesetDetailMatch[1]);
|
|
2716
|
+
const changesetId = decodeURIComponent(changesetDetailMatch[2]);
|
|
2717
|
+
try {
|
|
2718
|
+
const proxyRes = await fetch(`http://localhost:${USER_PORT}/changesets/${encodeURIComponent(database)}`, {
|
|
2719
|
+
method: "POST",
|
|
2720
|
+
headers: { "Content-Type": "application/json" },
|
|
2721
|
+
body: JSON.stringify({ changeset_id: changesetId }),
|
|
2722
|
+
});
|
|
2723
|
+
if (!proxyRes.ok)
|
|
2724
|
+
return jsonResponse({ error: "Failed to fetch changeset" }, proxyRes.status);
|
|
2725
|
+
const data = await proxyRes.json();
|
|
2726
|
+
return jsonResponse(data);
|
|
2727
|
+
}
|
|
2728
|
+
catch {
|
|
2729
|
+
return jsonResponse({ error: "Changeset API not available — is `mug dev` running?" }, 503);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
const changesetRollbackMatch = path.match(/^\/explorer\/api\/changesets\/([^/]+)\/([^/]+)\/rollback$/);
|
|
2733
|
+
if (changesetRollbackMatch && req.method === "POST") {
|
|
2734
|
+
const database = decodeURIComponent(changesetRollbackMatch[1]);
|
|
2735
|
+
const changesetId = decodeURIComponent(changesetRollbackMatch[2]);
|
|
2736
|
+
try {
|
|
2737
|
+
const proxyRes = await fetch(`http://localhost:${USER_PORT}/rollback/${encodeURIComponent(database)}`, {
|
|
2738
|
+
method: "POST",
|
|
2739
|
+
headers: { "Content-Type": "application/json" },
|
|
2740
|
+
body: JSON.stringify({ changeset_id: changesetId }),
|
|
2741
|
+
});
|
|
2742
|
+
if (!proxyRes.ok)
|
|
2743
|
+
return jsonResponse({ error: "Rollback failed" }, proxyRes.status);
|
|
2744
|
+
const data = await proxyRes.json();
|
|
2745
|
+
return jsonResponse(data);
|
|
2746
|
+
}
|
|
2747
|
+
catch {
|
|
2748
|
+
return jsonResponse({ error: "Rollback API not available" }, 503);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
if (path === "/explorer/api/usage") {
|
|
2752
|
+
try {
|
|
2753
|
+
const token = getAccountToken();
|
|
2754
|
+
const workspace = config.name;
|
|
2755
|
+
let apiUrl = `https://api.mug.work/workspace/${workspace}/usage`;
|
|
2756
|
+
const periodParam = url.searchParams.get("period");
|
|
2757
|
+
if (periodParam)
|
|
2758
|
+
apiUrl += `?period=${encodeURIComponent(periodParam)}`;
|
|
2759
|
+
const result = await fetch(apiUrl, {
|
|
2760
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2761
|
+
});
|
|
2762
|
+
const data = await result.json();
|
|
2763
|
+
if (!result.ok)
|
|
2764
|
+
return jsonResponse(data, result.status);
|
|
2765
|
+
return jsonResponse(data);
|
|
2766
|
+
}
|
|
2767
|
+
catch (err) {
|
|
2768
|
+
return jsonResponse({ error: `Failed to fetch usage: ${err}` }, 500);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
if (path === "/explorer/api/workspace-info") {
|
|
2772
|
+
try {
|
|
2773
|
+
const token = getAccountToken();
|
|
2774
|
+
const workspace = config.name;
|
|
2775
|
+
const [meRes, billingRes] = await Promise.all([
|
|
2776
|
+
fetch("https://api.mug.work/auth/me", { headers: { Authorization: `Bearer ${token}` } }),
|
|
2777
|
+
fetch(`https://api.mug.work/workspace/${workspace}/billing`, { headers: { Authorization: `Bearer ${token}` } }),
|
|
2778
|
+
]);
|
|
2779
|
+
const meData = (await meRes.json());
|
|
2780
|
+
const ws = (meData.workspaces ?? []).find((w) => w.name === workspace);
|
|
2781
|
+
const billing = billingRes.ok ? await billingRes.json() : {};
|
|
2782
|
+
return jsonResponse({ workspace: ws ?? null, billing });
|
|
2783
|
+
}
|
|
2784
|
+
catch (err) {
|
|
2785
|
+
return jsonResponse({ error: `Failed to fetch workspace info: ${err}` }, 500);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
if (path === "/explorer/api/files" || path.startsWith("/explorer/api/files/")) {
|
|
2789
|
+
const subpath = path === "/explorer/api/files" ? "" : decodeURIComponent(path.slice("/explorer/api/files/".length));
|
|
2790
|
+
const dirPath = join(cwd, "files", subpath);
|
|
2791
|
+
if (!existsSync(dirPath))
|
|
2792
|
+
return jsonResponse({ error: "Directory not found" }, 404);
|
|
2793
|
+
try {
|
|
2794
|
+
const entries = readdirSync(dirPath, { withFileTypes: true })
|
|
2795
|
+
.filter((e) => !e.name.startsWith("."))
|
|
2796
|
+
.map((e) => {
|
|
2797
|
+
const fp = join(dirPath, e.name);
|
|
2798
|
+
const ext = e.name.split(".").pop()?.toLowerCase() ?? "";
|
|
2799
|
+
let sizeBytes = 0;
|
|
2800
|
+
try {
|
|
2801
|
+
sizeBytes = statSync(fp).size;
|
|
2802
|
+
}
|
|
2803
|
+
catch { }
|
|
2804
|
+
return { name: e.name, sizeBytes, type: ext, isDir: e.isDirectory() };
|
|
2805
|
+
})
|
|
2806
|
+
.sort((a, b) => {
|
|
2807
|
+
if (a.isDir !== b.isDir)
|
|
2808
|
+
return a.isDir ? -1 : 1;
|
|
2809
|
+
return a.name.localeCompare(b.name);
|
|
2810
|
+
});
|
|
2811
|
+
return jsonResponse({ path: subpath || "/", files: entries });
|
|
2812
|
+
}
|
|
2813
|
+
catch {
|
|
2814
|
+
return jsonResponse({ error: "Failed to read directory" }, 500);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
jsonResponse({ error: "Not found" }, 404);
|
|
2818
|
+
}
|