Add minimal Workspace UI consuming /v1/space/workspace

This commit is contained in:
Carl Niklas Rydberg 2026-01-24 22:16:40 +01:00
parent 3ada8d6a71
commit 5a140178a1
3 changed files with 323 additions and 18 deletions

View file

@ -339,6 +339,13 @@ curl --unix-socket amduatd.sock \
-H 'X-Amduat-Space: demo' -H 'X-Amduat-Space: demo'
``` ```
## Workspace UI
`/workspace` serves a minimal, human-facing page that consumes
`/v1/space/workspace` and `/v1/space/mounts/sync/until`. It is a convenience
view for inspection and manual sync control, not a stable API. For
programmatic use, call the `/v1/*` endpoints directly.
## Space mounts sync (track mounts) ## Space mounts sync (track mounts)
`/v1/space/mounts/sync/until` runs the federation pull/until loop for every `/v1/space/mounts/sync/until` runs the federation pull/until loop for every
@ -554,6 +561,9 @@ When the daemon uses the `fs` store backend, index-only checks are reported as
- response: `{program_ref}` - response: `{program_ref}`
- `POST /v1/context_frames` - `POST /v1/context_frames`
UI (human-facing, not an API contract):
- `GET /workspace` → minimal workspace snapshot + sync controls (uses `/v1/space/workspace`)
Receipt example (with v1.1 fields): Receipt example (with v1.1 fields):
```json ```json

View file

@ -133,6 +133,304 @@ static const uint64_t AMDUATD_FED_TICK_MS = 1000u;
static const size_t AMDUATD_FED_INGEST_MAX_BYTES = 8u * 1024u * 1024u; static const size_t AMDUATD_FED_INGEST_MAX_BYTES = 8u * 1024u * 1024u;
static const size_t AMDUATD_REF_TEXT_MAX = 256u; static const size_t AMDUATD_REF_TEXT_MAX = 256u;
static const size_t AMDUATD_SPACE_MANIFEST_MAX_BYTES = 64u * 1024u; static const size_t AMDUATD_SPACE_MANIFEST_MAX_BYTES = 64u * 1024u;
static const char k_amduatd_workspace_html[] =
"<!doctype html>\n"
"<html lang=\"en\">\n"
"<head>\n"
" <meta charset=\"utf-8\" />\n"
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n"
" <title>amduatd workspace</title>\n"
" <style>\n"
" :root{color-scheme:light dark;}\n"
" body{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,Arial,sans-serif;"
" margin:0;line-height:1.4;background:#0f1216;color:#e7edf4;}\n"
" .wrap{max-width:980px;margin:0 auto;padding:28px 20px 60px;}\n"
" h1{margin:0 0 12px;font-size:22px;letter-spacing:.02em;}\n"
" h2{margin:18px 0 10px;font-size:16px;}\n"
" p{margin:8px 0;color:#c9d2dc;}\n"
" .bar{display:flex;gap:12px;flex-wrap:wrap;align-items:center;"
" padding:12px 14px;border:1px solid rgba(120,130,140,.4);"
" border-radius:12px;background:rgba(120,130,140,.08);}\n"
" label{font-size:13px;color:#c9d2dc;}\n"
" input{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,monospace;"
" font-size:13px;padding:6px 8px;border-radius:8px;border:1px solid rgba(120,130,140,.4);"
" background:#141922;color:#e7edf4;min-width:200px;}\n"
" button{font-size:13px;padding:7px 12px;border-radius:8px;border:1px solid #5b6b7c;"
" background:#1b232f;color:#e7edf4;cursor:pointer;}\n"
" button:disabled{opacity:.6;cursor:default;}\n"
" .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}\n"
" .card{padding:14px;border-radius:12px;border:1px solid rgba(120,130,140,.4);"
" background:rgba(120,130,140,.06);}\n"
" .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,monospace;font-size:13px;}\n"
" table{width:100%;border-collapse:collapse;font-size:13px;}\n"
" th,td{border-bottom:1px solid rgba(120,130,140,.3);padding:8px 6px;text-align:left;}\n"
" th{color:#c9d2dc;font-weight:600;}\n"
" .muted{color:#9aa6b2;font-size:12px;}\n"
" pre{margin:8px 0 0;padding:10px;border-radius:8px;background:#12161e;color:#e7edf4;overflow:auto;}\n"
" details{margin-top:8px;}\n"
" summary{cursor:pointer;color:#c9d2dc;font-size:13px;}\n"
" .status-ok{color:#7dd56f;}\n"
" .status-bad{color:#f09a8d;}\n"
" @media (max-width:760px){.grid{grid-template-columns:1fr;}}\n"
" </style>\n"
"</head>\n"
"<body>\n"
" <div class=\"wrap\">\n"
" <h1>Workspace snapshot</h1>\n"
" <p class=\"muted\">Local, read-only view of the effective space.</p>\n"
" <div class=\"bar\">\n"
" <label for=\"space\">Space (optional)</label>\n"
" <input id=\"space\" placeholder=\"space-id\" />\n"
" <button id=\"refresh\">Refresh</button>\n"
" <button id=\"sync\">Sync mounts</button>\n"
" <span id=\"syncing\" class=\"muted\" hidden>syncing...</span>\n"
" <span id=\"space_error\" class=\"muted\" hidden>invalid space id (no \"/\" or \"..\")</span>\n"
" </div>\n"
"\n"
" <div id=\"message\" class=\"card\" style=\"margin-top:16px;\" hidden></div>\n"
"\n"
" <div class=\"grid\" style=\"margin-top:16px;\">\n"
" <div class=\"card\">\n"
" <h2>Effective space</h2>\n"
" <div id=\"effective\" class=\"mono\">-</div>\n"
" <h2>Store backend</h2>\n"
" <div id=\"backend\" class=\"mono\">-</div>\n"
" <h2>Federation</h2>\n"
" <div id=\"federation\" class=\"mono\">-</div>\n"
" <h2>Manifest ref</h2>\n"
" <div id=\"manifest_ref\" class=\"mono\">-</div>\n"
" </div>\n"
" <div class=\"card\">\n"
" <h2>Store capabilities</h2>\n"
" <div id=\"capabilities\" class=\"mono\">-</div>\n"
" <h2>Sync result</h2>\n"
" <div id=\"sync_summary\" class=\"mono\">-</div>\n"
" <details id=\"sync_details\" hidden>\n"
" <summary>Full sync response</summary>\n"
" <pre id=\"sync_raw\"></pre>\n"
" </details>\n"
" </div>\n"
" </div>\n"
"\n"
" <div class=\"card\" style=\"margin-top:16px;\">\n"
" <h2>Mounts</h2>\n"
" <table id=\"mounts_table\">\n"
" <thead>\n"
" <tr>\n"
" <th>Name</th>\n"
" <th>Peer</th>\n"
" <th>Remote space</th>\n"
" <th>Mode</th>\n"
" <th>Cursor</th>\n"
" <th>Status</th>\n"
" </tr>\n"
" </thead>\n"
" <tbody id=\"mounts_body\"></tbody>\n"
" </table>\n"
" </div>\n"
"\n"
" <div class=\"card\" style=\"margin-top:16px;\">\n"
" <h2>Raw workspace JSON</h2>\n"
" <pre id=\"workspace_raw\"></pre>\n"
" </div>\n"
" </div>\n"
"\n"
" <script>\n"
" const spaceInput = document.getElementById('space');\n"
" const spaceError = document.getElementById('space_error');\n"
" const message = document.getElementById('message');\n"
" const refreshBtn = document.getElementById('refresh');\n"
" const syncBtn = document.getElementById('sync');\n"
" const syncing = document.getElementById('syncing');\n"
" const effectiveEl = document.getElementById('effective');\n"
" const backendEl = document.getElementById('backend');\n"
" const federationEl = document.getElementById('federation');\n"
" const manifestRefEl = document.getElementById('manifest_ref');\n"
" const capabilitiesEl = document.getElementById('capabilities');\n"
" const mountsBody = document.getElementById('mounts_body');\n"
" const workspaceRaw = document.getElementById('workspace_raw');\n"
" const syncSummary = document.getElementById('sync_summary');\n"
" const syncDetails = document.getElementById('sync_details');\n"
" const syncRaw = document.getElementById('sync_raw');\n"
"\n"
" function getSpaceHeader() {\n"
" const raw = spaceInput.value.trim();\n"
" if (!raw) {\n"
" spaceError.hidden = true;\n"
" return null;\n"
" }\n"
" if (raw.includes('/') || raw.includes('..')) {\n"
" spaceError.hidden = false;\n"
" return null;\n"
" }\n"
" spaceError.hidden = true;\n"
" return raw;\n"
" }\n"
"\n"
" function clearMessage() {\n"
" message.hidden = true;\n"
" message.textContent = '';\n"
" }\n"
"\n"
" function showMessage(text) {\n"
" message.hidden = false;\n"
" message.textContent = text;\n"
" }\n"
"\n"
" function setMonospaceText(el, text) {\n"
" el.textContent = text || '-';\n"
" }\n"
"\n"
" function renderCapabilities(caps) {\n"
" if (!caps || !caps.store_ops) {\n"
" setMonospaceText(capabilitiesEl, '-');\n"
" return;\n"
" }\n"
" const keys = ['put','get','put_indexed','get_indexed','tombstone','tombstone_lift','log_scan','current_state','validate_config'];\n"
" const lines = keys.map((key) => `${key}: ${caps.store_ops[key] === true ? 'true' : 'false'}`);\n"
" capabilitiesEl.textContent = lines.join('\\n');\n"
" }\n"
"\n"
" function renderMounts(mounts) {\n"
" mountsBody.innerHTML = '';\n"
" if (!Array.isArray(mounts)) {\n"
" return;\n"
" }\n"
" mounts.forEach((mount) => {\n"
" const row = document.createElement('tr');\n"
" const cursor = mount.mode === 'track' ? mount.tracking : null;\n"
" const cursorText = cursor && cursor.pull_cursor\n"
" ? `present=${cursor.pull_cursor.present}` +\n"
" (cursor.pull_cursor.last_logseq !== undefined ? ` last_logseq=${cursor.pull_cursor.last_logseq}` : '')\n"
" : 'present=false';\n"
" const notes = mount.status && Array.isArray(mount.status.notes)\n"
" ? mount.status.notes.join(', ')\n"
" : '';\n"
" const statusText = mount.status && mount.status.ok === true ? 'ok' : 'not-ok';\n"
" row.innerHTML = `\n"
" <td class=\"mono\">${mount.name || ''}</td>\n"
" <td class=\"mono\">${mount.peer_key || ''}</td>\n"
" <td class=\"mono\">${mount.remote_space_id || ''}</td>\n"
" <td>${mount.mode || ''}</td>\n"
" <td class=\"mono\">${cursorText}</td>\n"
" <td class=\"mono\">${statusText}${notes ? ` (${notes})` : ''}</td>\n"
" `;\n"
" mountsBody.appendChild(row);\n"
" });\n"
" }\n"
"\n"
" async function loadWorkspace() {\n"
" clearMessage();\n"
" const space = getSpaceHeader();\n"
" if (spaceError.hidden === false) {\n"
" return;\n"
" }\n"
" const headers = {};\n"
" if (space) {\n"
" headers['X-Amduat-Space'] = space;\n"
" }\n"
" let res;\n"
" try {\n"
" res = await fetch('/v1/space/workspace', { headers });\n"
" } catch (err) {\n"
" showMessage('Failed to reach /v1/space/workspace.');\n"
" return;\n"
" }\n"
" if (res.status === 404) {\n"
" showMessage('No manifest configured for this space. Use PUT /v1/space/manifest to create one.');\n"
" workspaceRaw.textContent = '';\n"
" renderMounts([]);\n"
" return;\n"
" }\n"
" const raw = await res.text();\n"
" if (!res.ok) {\n"
" showMessage(`Request failed (${res.status}).`);\n"
" workspaceRaw.textContent = raw;\n"
" return;\n"
" }\n"
" workspaceRaw.textContent = raw;\n"
" let data;\n"
" try {\n"
" data = JSON.parse(raw);\n"
" } catch (err) {\n"
" showMessage('Invalid JSON response.');\n"
" return;\n"
" }\n"
" if (data.effective_space) {\n"
" const mode = data.effective_space.mode || 'unknown';\n"
" const sid = data.effective_space.space_id || 'null';\n"
" setMonospaceText(effectiveEl, `${mode} (${sid})`);\n"
" } else {\n"
" setMonospaceText(effectiveEl, '-');\n"
" }\n"
" setMonospaceText(backendEl, data.store_backend || '-');\n"
" if (data.federation) {\n"
" const enabled = data.federation.enabled ? 'enabled' : 'disabled';\n"
" const transport = data.federation.transport || 'unknown';\n"
" setMonospaceText(federationEl, `${enabled} (${transport})`);\n"
" } else {\n"
" setMonospaceText(federationEl, '-');\n"
" }\n"
" setMonospaceText(manifestRefEl, data.manifest_ref || '-');\n"
" renderCapabilities(data.capabilities);\n"
" renderMounts(data.mounts);\n"
" }\n"
"\n"
" async function syncMounts() {\n"
" clearMessage();\n"
" const space = getSpaceHeader();\n"
" if (spaceError.hidden === false) {\n"
" return;\n"
" }\n"
" syncBtn.disabled = true;\n"
" syncing.hidden = false;\n"
" syncSummary.textContent = 'syncing...';\n"
" const headers = {};\n"
" if (space) {\n"
" headers['X-Amduat-Space'] = space;\n"
" }\n"
" let res;\n"
" try {\n"
" res = await fetch('/v1/space/mounts/sync/until?limit=128&max_rounds=10&max_mounts=32', {\n"
" method: 'POST',\n"
" headers\n"
" });\n"
" } catch (err) {\n"
" showMessage('Failed to reach /v1/space/mounts/sync/until.');\n"
" syncBtn.disabled = false;\n"
" syncing.hidden = true;\n"
" return;\n"
" }\n"
" const raw = await res.text();\n"
" syncRaw.textContent = raw;\n"
" syncDetails.hidden = false;\n"
" let data;\n"
" try {\n"
" data = JSON.parse(raw);\n"
" } catch (err) {\n"
" syncSummary.textContent = `Request failed (${res.status}).`;\n"
" syncBtn.disabled = false;\n"
" syncing.hidden = true;\n"
" return;\n"
" }\n"
" const ok = data.ok === true;\n"
" const synced = data.mounts_synced !== undefined ? data.mounts_synced : '-';\n"
" let failures = 0;\n"
" if (Array.isArray(data.results)) {\n"
" failures = data.results.filter((r) => r && r.ok === false).length;\n"
" }\n"
" const statusClass = ok ? 'status-ok' : 'status-bad';\n"
" syncSummary.innerHTML = `<span class=\"${statusClass}\">ok=${ok}</span> mounts_synced=${synced} failures=${failures}`;\n"
" syncBtn.disabled = false;\n"
" syncing.hidden = true;\n"
" }\n"
"\n"
" refreshBtn.addEventListener('click', loadWorkspace);\n"
" syncBtn.addEventListener('click', syncMounts);\n"
" window.addEventListener('load', loadWorkspace);\n"
" </script>\n"
"</body>\n"
"</html>\n";
static const char k_amduatd_contract_v1_json[] = static const char k_amduatd_contract_v1_json[] =
"{" "{"
"\"contract\":\"AMDUATD/API/1\"," "\"contract\":\"AMDUATD/API/1\","
@ -1848,6 +2146,16 @@ mounts_sync_cleanup:
return ok; return ok;
} }
static bool amduatd_handle_get_workspace_ui(int fd) {
return amduatd_http_send_status(fd,
200,
"OK",
"text/html; charset=utf-8",
(const uint8_t *)k_amduatd_workspace_html,
strlen(k_amduatd_workspace_html),
false);
}
static bool amduatd_handle_get_space_workspace( static bool amduatd_handle_get_space_workspace(
int fd, int fd,
amduat_asl_store_t *store, amduat_asl_store_t *store,
@ -9101,6 +9409,10 @@ static bool amduatd_handle_conn(int fd,
ok = amduatd_handle_meta(fd, cfg, api_contract_ref, false); ok = amduatd_handle_meta(fd, cfg, api_contract_ref, false);
goto conn_cleanup; goto conn_cleanup;
} }
if (strcmp(req.method, "GET") == 0 && strcmp(no_query, "/workspace") == 0) {
ok = amduatd_handle_get_workspace_ui(fd);
goto conn_cleanup;
}
if (strcmp(req.method, "HEAD") == 0 && strcmp(no_query, "/v1/meta") == 0) { if (strcmp(req.method, "HEAD") == 0 && strcmp(no_query, "/v1/meta") == 0) {
ok = amduatd_handle_meta(fd, cfg, api_contract_ref, true); ok = amduatd_handle_meta(fd, cfg, api_contract_ref, true);
goto conn_cleanup; goto conn_cleanup;

View file

@ -8,24 +8,7 @@
#include <string.h> #include <string.h>
static bool amduatd_space_name_valid(const char *name) { static bool amduatd_space_name_valid(const char *name) {
size_t i; return amduat_asl_pointer_name_is_valid(name);
size_t len;
if (name == NULL) {
return false;
}
len = strlen(name);
if (len == 0 || len > 64) {
return false;
}
for (i = 0; i < len; ++i) {
unsigned char c = (unsigned char)name[i];
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' ||
c == '-' || c == '.' || c == '/') {
continue;
}
return false;
}
return true;
} }
static bool amduatd_space_strdup_cstr(const char *s, char **out) { static bool amduatd_space_strdup_cstr(const char *s, char **out) {