diff --git a/CHANGELOG.md b/CHANGELOG.md
index df45468..31618dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,53 @@
All notable changes to Armature are documented here.
+## v0.7.9 — Workflow Independence Audit
+
+Workflows proven independent of chat and all optional packages. Critical
+rendering bugs fixed, dead chat UI removed, deferred test debt closed.
+
+**RenderWorkflow Fix (critical)**
+
+- `RenderWorkflow()` was a stub that never loaded instance/stage data — post-start page was broken. Now loads instance by token/ID, resolves current stage, populates all template fields.
+- `WorkflowPageData` fields renamed: `ChannelID` → `EntryToken`, `ChannelTitle` → `WorkflowTitle`, `ChannelDescription` → `WorkflowDescription` (vestigial chat-era names)
+- Stage modes aligned: template uses Go constants (`form`, `review`, `delegated`, `automated`) instead of legacy `form_only`/`form_chat`
+- Dead chat UI removed: chat CSS, split layout, `sendMessage()`, fallback chat branch (~180 lines deleted from `workflow.html`)
+- Landing page mode conditionals updated to match Go constants
+- OpenAPI `stage_mode` enum corrected (4 occurrences)
+
+**Bug Fixes (found during verification)**
+
+- Entry token resolution: handler passed route param (instance ID) as entry token to JS. Fixed: resolve actual token from `inst.EntryToken` + `?token=` query param.
+- Fieldset submit guard: `submitForm()` checked `FORM_TPL.fields` but not `.fieldsets` — progressive forms silently no-op'd. Fixed: accept either.
+- Stage advance status check: JS checked `result.status === 'advanced'` but API returns `active`/`completed`. Fixed: on 200 OK with `active`, reload to render next stage.
+
+**Deferred test coverage (carried from v0.7.6)**
+
+- 17 new SQLite store tests: workflow CRUD, stages, instances, lifecycle (advance/complete/cancel/stale), team scope, API tokens, users, groups
+- `InstallPackage` decomposed from 400-line monolith into 7 private methods: `receiveUpload`, `parseAndValidateArchive`, `extractPackageAssets`, `registerPackage`, `applySchemaAndPermissions`, `resolveDependencies`, `checkCapabilities`
+- `ci/e2e-workflow-nochat.sh` — E2E test for workflow lifecycle without chat
+
+**Discovered issues (deferred to v0.7.10)**
+
+- Public→authenticated stage handoff: visitor sees auth-gated stage form instead of "submitted" screen
+- Team member pickup UI: no surface for claiming workflow instances
+- Assignment flow: no admin UI for manual instance assignment
+
+**Tests:** 17 new store tests, 1 new E2E script
+
+---
+
+## v0.7.8 — Bug Fixes & Admin Gaps
+
+- `StartBySlug` handler + `/api/v1/workflow-entry/:scope/:slug` route for landing page Start button
+- Workflow delete guard: admin endpoint rejects team-scoped workflows (403)
+- Package button cleanup: Delete hidden for bundled packages
+- Package export: `fetch()` with auth token instead of `window.open()`
+- Settings CSS: bottom padding fix for save button cutoff
+- Shared `StageForm` component between admin and team-admin; public entry URL with copy button
+
+---
+
## v0.7.7 — API Tokens + Extension Permissions
Personal access tokens (PATs) for programmatic API access, plus extension-declared
diff --git a/ROADMAP.md b/ROADMAP.md
index f6832a5..9dd3fc4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,6 +1,6 @@
# Armature — Roadmap
-## Current: v0.7.7 — API Tokens + Extension Permissions
+## Current: v0.7.9 — Workflow Independence Audit
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
sandbox, storage, realtime, and ops are kernel primitives. Everything else
@@ -223,10 +223,10 @@ a test coverage gap in the store layer. Fix before v0.8.x kernel expansion.
| Step | Status | Description |
|------|--------|-------------|
-| store/ unit tests | | Deferred to v0.7.8. Direct tests for PG + SQLite store implementations. Requires live DB. Target: ≥30% SLOC ratio. |
+| store/ unit tests | done | Moved to v0.7.9. 17 SQLite store tests: workflow CRUD, stages, instances, lifecycle, tokens, users, groups. |
| workflow/ routing unit tests | done | 82 tests: ResolveNextStage, ResolveStageByName, ParseStageConfig, evaluateCondition (all operators). |
| middleware/ unit tests | done | 10 tests: RequirePermission, RequireAdmin, permission caching, RateLimiter (allow/deny/fail-open). |
-| InstallPackage decomposition | | Deferred to v0.7.8. 224 cognitive complexity. Split into: validate → create → DDL → permissions → triggers. |
+| InstallPackage decomposition | done | Moved to v0.7.9. Split into 7 phases: receiveUpload, parseAndValidateArchive, extractPackageAssets, registerPackage, applySchemaAndPermissions, resolveDependencies, checkCapabilities. |
**Bug Fixes**
@@ -285,16 +285,59 @@ Workflows must work end-to-end without chat or any 3rd-party package
dependency. Chat, notifications, and other packages should *enhance*
workflows but never be required for core operation.
+**RenderWorkflow Fix (critical)**
+
| Step | Status | Description |
|------|--------|-------------|
-| Public entry flow | | Verify workflow start → instance creation → stage progression works without chat package installed. Landing page → form → completion. |
-| Stage mode degradation | | All stage modes (`form_only`, `form_chat`, `review`) degrade gracefully when optional packages missing. `form_chat` without chat falls back to form-only. |
-| Instance lifecycle | | Create, advance, complete, cancel — full lifecycle without chat dependency. Engine doesn't error when chat unavailable. |
-| Admin/team-admin UI | | All CRUD, monitoring, and stage builder work without optional packages. No broken references. |
-| E2E without chat | | Landing page → instance → stage progression → completion tested with chat package disabled. |
-| Deferred test coverage | | store/ unit tests (PG + SQLite), InstallPackage decomposition, workflow/ engine integration tests. Carried from v0.7.6. |
+| Fix RenderWorkflow() handler | done | Was a stub (always `stageMode = "custom"`, no data loaded). Now loads instance by token/ID, resolves current stage, populates all template data. |
+| Rename WorkflowPageData fields | done | `ChannelID` → `EntryToken`, `ChannelTitle` → `WorkflowTitle`, `ChannelDescription` → `WorkflowDescription`. Vestigial chat-era names removed. |
+| Align stage mode values | done | Template now uses Go constants (`form`, `review`, `delegated`, `automated`) instead of legacy names (`form_only`, `form_chat`). |
+| Remove dead chat UI | done | Chat CSS, split layout, `sendMessage()` function, and fallback chat branch all removed from `workflow.html`. Replaced with clean form/review/unsupported modes. |
+| Fix landing page mode mapping | done | `workflow-landing.html` conditionals updated to match Go stage mode constants. |
+| Update OpenAPI spec | done | `stage_mode` enum changed from `[custom, form_only, form_chat, review]` to `[form, review, delegated, automated]`. |
-### v0.7.10 — Query & HTTP Ergonomics
+**Verification & Testing**
+
+| Step | Status | Description |
+|------|--------|-------------|
+| Admin/team-admin UI audit | done | All workflow CRUD, monitoring, stage builder verified clean — zero chat/chat-core references. |
+| E2E workflow test | done | `ci/e2e-workflow-nochat.sh` — creates workflow via API, adds form+review stages, starts instance via public entry, verifies landing + execution pages. |
+| Store unit tests (SQLite) | done | 17 tests: workflow CRUD, stages, instances, lifecycle (advance/complete/cancel/stale), team scope, API tokens, users, groups. Carried from v0.7.6. |
+| InstallPackage decomposition | done | Split 400-line handler into 7 private methods: `receiveUpload`, `parseAndValidateArchive`, `extractPackageAssets`, `registerPackage`, `applySchemaAndPermissions`, `resolveDependencies`, `checkCapabilities`. Carried from v0.7.6. |
+
+**Bug Fixes (found during verification)**
+
+| Step | Status | Description |
+|------|--------|-------------|
+| Entry token resolution | done | `RenderWorkflow()` passed route param (instance ID) as entry token. JS then sent instance ID to the advance API which expects entry token. Fixed: resolve actual token from `inst.EntryToken` + check `?token=` query param. |
+| Fieldset submit guard | done | `submitForm()` checked `FORM_TPL.fields` but not `.fieldsets` — progressive forms silently no-op'd on submit. Fixed: accept either. |
+| Stage advance status check | done | Template JS checked `result.status === 'advanced'` but API returns instance status (`active`/`completed`). Fixed: on 200 OK with `active` status, reload to render next stage. |
+
+**Discovered issues (deferred)**
+
+The audit uncovered deeper workflow UX gaps that are out of scope for v0.7.9:
+
+1. **Public→authenticated stage handoff** — After a public visitor completes their stages, the workflow advances to an authenticated stage (e.g. "classify"). The visitor sees the next stage's form + an auth error instead of a "thank you, we'll take it from here" message. The page should detect audience mismatch and show a completion/waiting screen.
+2. **Team member pickup UI** — No surface exists for team members to see workflow instances assigned to their team and claim/work on them. Currently only visible in team-admin monitoring.
+3. **Assignment flow** — No mechanism for team admins to assign a specific instance to a specific team member. The `workflow_assignments` table exists but has no admin UI for manual assignment.
+
+These will be addressed in a future version (see v0.7.10 below).
+
+### v0.7.10 — Workflow Handoff + Assignment UI
+
+Addresses the three UX gaps found during the v0.7.9 independence audit.
+The workflow engine and data model already support these flows — the
+missing pieces are page-level audience detection and team-facing UI.
+
+| Step | Status | Description |
+|------|--------|-------------|
+| Public stage completion screen | | When `RenderWorkflow()` detects the current stage's audience is `team`/`system` but the visitor is unauthenticated, render a "Submitted — your request is being reviewed" screen instead of the stage form. |
+| Team workflow inbox | | New section in team-admin (or dedicated surface): list active instances assigned to this team, with claim/unclaim actions. Uses existing `ListAssignmentsByTeam` store method. |
+| Manual assignment UI | | Team admin can assign an unclaimed instance to a specific team member. Uses existing `CreateAssignment` store method. |
+| Assignment notifications | | When an instance is assigned (auto or manual), notify the assignee via the notification system. |
+| E2E multi-stage test | | Full lifecycle: public form → team pickup → review → complete. Verified with both authenticated and unauthenticated users. |
+
+### v0.7.11 — Query & HTTP Ergonomics (was v0.7.10)
Four additions to existing sandbox modules. No new files beyond tests,
no schema changes. Motivated by real-world extension porting feedback:
@@ -309,7 +352,7 @@ remaining friction points for extensions migrating from native Go/Python.
| `http.batch()` | | `http.batch(requests)` → list of response dicts (ordered). Each request dict: `method`, `url`, `body`, `headers`. Concurrent dispatch via goroutines capped at 10, `sync.WaitGroup` collection. Reuses existing `httpDo` plumbing (SSRF checks, domain allowlist, body cap, timeout). Individual failures return error response dicts, don't abort batch. Permission: `api.http`. ~80 lines in `http_module.go`. |
| Tests | | Unit tests for all four builtins. `db.count`/`db.aggregate` test empty table, filtered, type coercion. `db.query_batch` test mixed specs, empty list. `http.batch` test concurrent execution, partial failure, ordering. PG + SQLite for db tests. |
-### v0.7.11 — Concurrent Execution Primitive
+### v0.7.12 — Concurrent Execution Primitive (was v0.7.11)
New `batch` sandbox module. Enables extensions to parallelize arbitrary
Starlark callables — including library function calls via `lib.require()`.
diff --git a/ci/e2e-workflow-nochat.sh b/ci/e2e-workflow-nochat.sh
new file mode 100755
index 0000000..da2e611
--- /dev/null
+++ b/ci/e2e-workflow-nochat.sh
@@ -0,0 +1,177 @@
+#!/usr/bin/env bash
+# ═══════════════════════════════════════════════
+# E2E Workflow Test — Without Chat
+# ═══════════════════════════════════════════════
+#
+# Verifies the complete workflow lifecycle works without chat or
+# chat-core packages installed. Uses only admin API + PAT auth.
+#
+# Proves: workflow independence from optional packages.
+#
+# Prerequisites:
+# - Server running at $SERVER_URL (default: http://localhost:3000)
+# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin)
+#
+# Exit codes: 0 = all assertions passed, 1 = failures detected
+set -euo pipefail
+
+SERVER_URL="${SERVER_URL:-http://localhost:3000}"
+ADMIN_USER="${ADMIN_USER:-admin}"
+ADMIN_PASS="${ADMIN_PASS:-admin}"
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+PASS=0
+FAIL=0
+
+assert() {
+ local desc="$1" ok="$2"
+ if [ "$ok" = "true" ]; then
+ echo -e " ${GREEN}✓${NC} $desc"
+ PASS=$((PASS + 1))
+ else
+ echo -e " ${RED}✗${NC} $desc"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+echo -e "${YELLOW}═══ E2E Workflow Independence Test ═══${NC}"
+echo " Server: ${SERVER_URL}"
+
+# ── Authenticate ─────────────────────────────
+TOKEN=""
+PAT_FILE="/tmp/armature-admin-pat.txt"
+
+if [ -f "$PAT_FILE" ]; then
+ TOKEN=$(cat "$PAT_FILE")
+fi
+
+if [ -z "$TOKEN" ]; then
+ TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
+ -H "Content-Type: application/json" \
+ -d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
+ | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})" 2>/dev/null || true)
+fi
+
+if [ -z "$TOKEN" ]; then
+ echo -e "${RED}Failed to authenticate${NC}"
+ exit 1
+fi
+echo -e "${GREEN}Authenticated${NC}"
+
+AUTH="Authorization: Bearer ${TOKEN}"
+
+# ── Verify chat is NOT installed ─────────────
+echo -e "\n${YELLOW}Phase 1: Verify chat packages not required${NC}"
+
+CHAT_PKG=$(curl -sf -H "$AUTH" "${SERVER_URL}/api/v1/admin/packages" \
+ | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{
+ const pkgs = JSON.parse(d).data || JSON.parse(d);
+ const chat = (Array.isArray(pkgs) ? pkgs : []).filter(p => p.id === 'chat' || p.id === 'chat-core');
+ console.log(JSON.stringify(chat));
+ })" 2>/dev/null || echo "[]")
+
+echo " Chat packages found: ${CHAT_PKG}"
+
+# ── Create a test workflow ───────────────────
+echo -e "\n${YELLOW}Phase 2: Create workflow via admin API${NC}"
+
+WF_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows" \
+ -H "$AUTH" -H "Content-Type: application/json" \
+ -d '{
+ "name": "E2E No-Chat Test",
+ "slug": "e2e-nochat-test",
+ "description": "Workflow independence E2E test",
+ "entry_mode": "public_link",
+ "is_active": true
+ }' 2>/dev/null || echo '{"error":"failed"}')
+
+WF_ID=$(echo "$WF_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null)
+
+assert "Workflow created" "$([ -n "$WF_ID" ] && echo true || echo false)"
+
+if [ -z "$WF_ID" ]; then
+ echo -e "${RED}Cannot continue without workflow ID${NC}"
+ echo "Response: ${WF_RESP}"
+ exit 1
+fi
+
+# ── Add a form stage ─────────────────────────
+echo -e "\n${YELLOW}Phase 3: Add form stage${NC}"
+
+STAGE_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \
+ -H "$AUTH" -H "Content-Type: application/json" \
+ -d '{
+ "name": "Intake Form",
+ "stage_mode": "form",
+ "ordinal": 0,
+ "form_template": {
+ "fields": [
+ {"key": "title", "type": "text", "label": "Issue Title", "required": true},
+ {"key": "description", "type": "textarea", "label": "Description"}
+ ]
+ }
+ }' 2>/dev/null || echo '{"error":"failed"}')
+
+STAGE_ID=$(echo "$STAGE_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null)
+
+assert "Form stage created" "$([ -n "$STAGE_ID" ] && echo true || echo false)"
+
+# ── Add a review stage ───────────────────────
+REVIEW_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" \
+ -H "$AUTH" -H "Content-Type: application/json" \
+ -d '{
+ "name": "Manager Review",
+ "stage_mode": "review",
+ "ordinal": 1
+ }' 2>/dev/null || echo '{"error":"failed"}')
+
+REVIEW_ID=$(echo "$REVIEW_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null)
+
+assert "Review stage created" "$([ -n "$REVIEW_ID" ] && echo true || echo false)"
+
+# ── Landing page responds ────────────────────
+echo -e "\n${YELLOW}Phase 4: Landing page accessible${NC}"
+
+LANDING_STATUS=$(curl -so /dev/null -w "%{http_code}" "${SERVER_URL}/w/global/e2e-nochat-test" 2>/dev/null || echo "000")
+assert "Landing page returns 200" "$([ "$LANDING_STATUS" = "200" ] && echo true || echo false)"
+
+# ── Start workflow via public API ────────────
+echo -e "\n${YELLOW}Phase 5: Start workflow (public entry)${NC}"
+
+START_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflow-entry/global/e2e-nochat-test" \
+ -H "Content-Type: application/json" \
+ -d '{}' 2>/dev/null || echo '{"error":"failed"}')
+
+INST_ID=$(echo "$START_RESP" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c; process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'')}catch(e){console.log('')}})" 2>/dev/null)
+
+assert "Instance created" "$([ -n "$INST_ID" ] && echo true || echo false)"
+
+# ── Workflow page renders ────────────────────
+echo -e "\n${YELLOW}Phase 6: Workflow execution page${NC}"
+
+if [ -n "$INST_ID" ]; then
+ WF_PAGE_STATUS=$(curl -so /dev/null -w "%{http_code}" "${SERVER_URL}/w/${INST_ID}" 2>/dev/null || echo "000")
+ assert "Workflow page returns 200" "$([ "$WF_PAGE_STATUS" = "200" ] && echo true || echo false)"
+fi
+
+# ── Cleanup: delete workflow ─────────────────
+echo -e "\n${YELLOW}Cleanup${NC}"
+
+curl -sf -X DELETE "${SERVER_URL}/api/v1/workflows/${WF_ID}" \
+ -H "$AUTH" >/dev/null 2>&1 || true
+echo -e " Deleted test workflow"
+
+# ── Summary ──────────────────────────────────
+echo ""
+TOTAL=$((PASS + FAIL))
+if [ $FAIL -eq 0 ]; then
+ echo -e "${GREEN}═══ All ${TOTAL} assertions passed ═══${NC}"
+ exit 0
+else
+ echo -e "${RED}═══ ${FAIL}/${TOTAL} assertions failed ═══${NC}"
+ exit 1
+fi
diff --git a/packages/editor/css/main.css b/packages/editor/css/main.css
deleted file mode 100644
index cdd7782..0000000
--- a/packages/editor/css/main.css
+++ /dev/null
@@ -1,531 +0,0 @@
-/* ==========================================
- Armature — Editor Surface (v0.25.0)
- ==========================================
- Replaces editor-mode.css for the pane-based editor.
- Covers: topbar, bootstrap, and component-specific
- overrides within the editor context.
- ========================================== */
-
-/* ── Surface Shell ─────────────────────────── */
-
-.ext-editor {
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
- background: var(--bg);
-}
-
-/* ── Topbar ────────────────────────────────── */
-
-.ext-editor-topbar {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- padding: 0 var(--sp-3);
- height: 40px;
- flex-shrink: 0;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border);
- font-size: 13px;
- position: relative;
- z-index: 20;
-}
-
-.ext-editor-topbar-back {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- color: var(--text-3);
- text-decoration: none;
- font-size: 12px;
- font-weight: 500;
- padding: var(--sp-1) var(--sp-2);
- border-radius: var(--radius-sm);
- transition: color 0.15s, background 0.15s;
-}
-
-.ext-editor-topbar-back:hover {
- color: var(--text);
- background: var(--bg-hover);
-}
-
-.ext-editor-topbar-sep {
- width: 1px;
- height: 18px;
- background: var(--border);
-}
-
-.ext-editor-topbar-name {
- font-size: 13px;
- font-weight: 600;
- color: var(--text);
-}
-
-/* ── Workspace Selector ────────────────────── */
-
-.ext-editor-ws-selector {
- position: relative;
-}
-
-.ext-editor-ws-selector-btn {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- background: none;
- border: 1px solid transparent;
- color: var(--text);
- font-size: 13px;
- font-weight: 600;
- font-family: inherit;
- padding: var(--sp-1) var(--sp-2);
- border-radius: var(--radius-sm);
- cursor: pointer;
- transition: border-color 0.15s, background 0.15s;
-}
-
-.ext-editor-ws-selector-btn:hover {
- border-color: var(--border);
- background: var(--bg-hover);
-}
-
-.ext-editor-ws-dropdown {
- display: none;
- position: absolute;
- top: 100%;
- left: 0;
- margin-top: var(--sp-1);
- background: var(--bg-secondary);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- min-width: 220px;
- max-height: 320px;
- overflow-y: auto;
- z-index: 1000;
- box-shadow: 0 4px 16px rgba(0,0,0,0.4);
- padding: var(--sp-1) 0;
-}
-
-.ext-editor-ws-dropdown.open { display: block; }
-
-.ext-editor-ws-list {
- max-height: 240px;
- overflow-y: auto;
-}
-
-.ext-editor-ws-dropdown-item {
- display: block;
- width: 100%;
- text-align: left;
- background: none;
- border: none;
- color: var(--text);
- font-size: 12px;
- font-family: inherit;
- padding: var(--sp-2) var(--sp-3);
- cursor: pointer;
- transition: background 0.1s;
-}
-
-.ext-editor-ws-dropdown-item:hover {
- background: var(--bg-hover);
-}
-
-.ext-editor-ws-dropdown-item.active {
- color: var(--accent);
- font-weight: 600;
-}
-
-.ext-editor-ws-dropdown-divider {
- height: 1px;
- background: var(--border);
- margin: var(--sp-1) 0;
-}
-
-.ext-editor-ws-new {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- color: var(--accent);
-}
-
-.ext-editor-topbar-branch {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- background: var(--purple-dim);
- padding: 2px var(--sp-2);
- border-radius: var(--radius-sm);
-}
-
-.ext-editor-topbar-branch-text {
- font-size: 11px;
- font-weight: 600;
- color: var(--purple);
- font-family: var(--mono, 'SF Mono', monospace);
-}
-
-/* ── Body ──────────────────────────────────── */
-
-.ext-editor-body {
- flex: 1;
- min-height: 0;
- overflow: hidden;
-}
-
-/* ── Bootstrap (no workspace) ──────────────── */
-
-.ext-editor-bootstrap {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.ext-editor-bootstrap-card {
- text-align: center;
- padding: var(--sp-10);
- background: var(--bg-secondary);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- max-width: 360px;
-}
-
-.ext-editor-bootstrap-input {
- width: 100%;
- padding: var(--sp-2) var(--sp-3);
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-size: 13px;
- font-family: inherit;
- outline: none;
- margin-bottom: var(--sp-3);
- box-sizing: border-box;
-}
-
-.ext-editor-bootstrap-input:focus {
- border-color: var(--accent);
-}
-
-.ext-editor-bootstrap-btn {
- width: 100%;
- padding: var(--sp-3) var(--sp-4);
- background: var(--accent);
- color: #fff;
- border: none;
- border-radius: var(--radius);
- font-size: 13px;
- font-weight: 600;
- font-family: inherit;
- cursor: pointer;
- transition: opacity 0.15s;
-}
-
-.ext-editor-bootstrap-btn:hover { opacity: 0.9; }
-.ext-editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
-
-/* Workspace list in bootstrap */
-.ext-editor-bootstrap-ws-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding: var(--sp-3) var(--sp-3);
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-size: 13px;
- font-family: inherit;
- cursor: pointer;
- transition: border-color 0.15s, background 0.15s;
- margin-bottom: var(--sp-2);
- text-align: left;
-}
-
-.ext-editor-bootstrap-ws-item:hover {
- border-color: var(--accent);
- background: var(--bg-hover);
-}
-
-.ext-editor-bootstrap-ws-name {
- font-weight: 600;
-}
-
-.ext-editor-bootstrap-ws-date {
- font-size: 11px;
- color: var(--text-3);
-}
-
-/* ── FileTree overrides (in editor context) ── */
-
-.ext-editor .file-tree {
- height: 100%;
- display: flex;
- flex-direction: column;
- border-right: 1px solid var(--border);
-}
-
-.ext-editor .file-tree-header {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- padding: var(--sp-2) var(--sp-3);
- border-bottom: 1px solid var(--border);
-}
-
-.ext-editor .file-tree-title {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-2);
- text-transform: uppercase;
- letter-spacing: 0.4px;
- flex: 1;
-}
-
-.ext-editor .file-tree-items {
- flex: 1;
- overflow-y: auto;
- padding: var(--sp-1) 0;
-}
-
-.ext-editor .file-tree-row {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- padding: 3px var(--sp-2);
- cursor: pointer;
- font-size: 12px;
- color: var(--text-2);
- transition: background 0.1s;
-}
-
-.ext-editor .file-tree-row:hover {
- background: var(--bg-hover);
-}
-
-.ext-editor .file-tree-row.active {
- background: var(--accent-dim);
- color: var(--text);
-}
-
-.ext-editor .file-tree-arrow {
- width: 12px;
- font-size: 10px;
- color: var(--text-3);
- text-align: center;
-}
-
-.ext-editor .file-tree-icon {
- font-size: 13px;
- width: 18px;
- text-align: center;
-}
-
-.ext-editor .file-tree-name {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Git status indicators */
-.ext-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning); }
-.ext-editor .file-tree-row.git-added .file-tree-name { color: var(--success); }
-.ext-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3); font-style: italic; }
-.ext-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger); text-decoration: line-through; }
-
-/* Context menu */
-.ext-editor-file-tree-ctx-menu {
- position: fixed;
- background: var(--bg-secondary);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: var(--sp-1) 0;
- min-width: 120px;
- z-index: 1000;
- box-shadow: 0 4px 12px rgba(0,0,0,0.4);
-}
-
-.ext-editor-file-tree-ctx-item {
- padding: var(--sp-2) var(--sp-3);
- font-size: 12px;
- color: var(--text);
- cursor: pointer;
-}
-
-.ext-editor-file-tree-ctx-item:hover {
- background: var(--bg-hover);
-}
-
-/* ── CodeEditor overrides ──────────────────── */
-
-.ext-editor .code-editor {
- height: 100%;
- display: flex;
- flex-direction: column;
-}
-
-.ext-editor .code-editor-tabs {
- display: flex;
- align-items: center;
- gap: 0;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border);
- height: 32px;
- overflow-x: auto;
- flex-shrink: 0;
-}
-
-.ext-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
-
-.ext-editor .code-editor-tab {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- padding: 0 var(--sp-3);
- height: 100%;
- font-size: 12px;
- color: var(--text-3);
- cursor: pointer;
- border-right: 1px solid var(--border);
- transition: background 0.1s;
- white-space: nowrap;
-}
-
-.ext-editor .code-editor-tab:hover { background: var(--bg-hover); }
-.ext-editor .code-editor-tab.active { color: var(--text); background: var(--bg); }
-.ext-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning); }
-
-.ext-editor .code-editor-tab-icon { font-size: 12px; }
-.ext-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
-
-.ext-editor .code-editor-tab-close {
- background: none;
- border: none;
- color: var(--text-3);
- font-size: 12px;
- cursor: pointer;
- padding: 0 2px;
- margin-left: var(--sp-1);
- border-radius: var(--radius-sm);
- line-height: 1;
-}
-
-.ext-editor .code-editor-tab-close:hover {
- background: var(--danger-dim);
- color: var(--danger);
-}
-
-.ext-editor .code-editor-content {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- position: relative;
-}
-
-.ext-editor .code-editor-welcome {
- height: 100%;
-}
-
-.ext-editor .code-editor-cm-wrap {
- height: 100%;
- overflow: auto;
-}
-
-.ext-editor .code-editor-cm-wrap .cm-editor {
- height: 100%;
-}
-
-.ext-editor .code-editor-statusbar {
- display: flex;
- align-items: center;
- gap: var(--sp-4);
- padding: 0 var(--sp-3);
- height: 24px;
- flex-shrink: 0;
- background: var(--bg-secondary);
- border-top: 1px solid var(--border);
- font-size: 11px;
- color: var(--text-3);
- font-family: var(--mono, 'SF Mono', monospace);
-}
-
-.ext-editor .code-editor-textarea-fallback {
- width: 100%;
- height: 100%;
- background: var(--bg);
- color: var(--text);
- border: none;
- padding: var(--sp-3);
- font-family: var(--mono, 'SF Mono', monospace);
- font-size: 13px;
- resize: none;
- outline: none;
- box-sizing: border-box;
-}
-
-/* ── Tabbed assist pane overrides ──────────── */
-
-.ext-editor .pane-tabbed {
- border-left: 1px solid var(--border);
-}
-
-/* ChatPane in editor tabbed pane */
-.ext-editor .chat-pane {
- position: absolute;
- inset: 0;
-}
-
-/* Notes in editor pane */
-.ext-editor .ext-notes-editor {
- position: absolute;
- inset: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.ext-editor .ext-notes-editor-list-view {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.ext-editor .ext-notes-list {
- flex: 1;
- overflow-y: auto;
-}
-
-/* Compact notes toolbar for narrow pane */
-.ext-editor .ext-notes-toolbar {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-1);
- padding: var(--sp-2) var(--sp-2);
- border-bottom: 1px solid var(--border);
-}
-
-.ext-editor .ext-notes-toolbar .sw-btn--sm {
- font-size: 11px;
- padding: 3px var(--sp-2);
-}
-
-.ext-editor .ext-notes-search-row {
- padding: var(--sp-1) var(--sp-2);
-}
-
-.ext-editor .ext-notes-filter-row {
- padding: 2px var(--sp-2) var(--sp-1);
- display: flex;
- gap: var(--sp-1);
-}
-
-.ext-editor .ext-notes-filter-select {
- font-size: 11px;
- flex: 1;
- min-width: 0;
-}
diff --git a/packages/editor/js/main.js b/packages/editor/js/main.js
deleted file mode 100644
index 79a8594..0000000
--- a/packages/editor/js/main.js
+++ /dev/null
@@ -1,473 +0,0 @@
-// ==========================================
-// Armature — Editor Package (v0.31.0)
-// ==========================================
-// Installable .pkg that provides the code editor surface.
-// Mounts into #extension-mount (surface-extension template).
-//
-// Uses Component.mount() for all sub-components — single source
-// of truth for DOM structure. No duplicated template partials.
-//
-// Dependencies (loaded by base.html):
-// FileTree, CodeEditor, ChatPane, NotePanel, note-graph, PaneContainer
-// API, UI, App, sw, esc (ui-primitives)
-//
-// Dynamically loaded:
-// codemirror.bundle.js
-// ==========================================
-
-(function () {
- 'use strict';
-
- const SURFACE_ID = 'editor';
- const PREFIX = 'ed';
- const NOTES_PREFIX = 'edNotes';
- const STATE_DEBOUNCE_MS = 2000;
-
- if (window.__SURFACE__ !== SURFACE_ID) return;
-
- const base = window.__BASE__ || '';
-
- // ── Dynamic Script Loader ────────────────────
-
- function _loadScript(src) {
- return new Promise((resolve, reject) => {
- if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) {
- resolve();
- return;
- }
- const s = document.createElement('script');
- s.src = base + src;
- s.type = 'module';
- s.onload = resolve;
- s.onerror = () => { resolve(); }; // Non-fatal
- document.head.appendChild(s);
- });
- }
-
- // ── Init ─────────────────────────────────────
-
- async function _init() {
- const mount = document.getElementById('extension-mount');
- if (!mount) return;
-
- // Hide server-rendered user menu (we mount our own in the topbar)
- const serverMenu = document.getElementById('userMenuWrap');
- if (serverMenu) serverMenu.style.display = 'none';
-
- // Wrap in surface container for CSS scoping
- const surface = document.createElement('div');
- surface.className = 'ext-editor';
- surface.id = 'editorSurface';
- mount.appendChild(surface);
-
- // Read workspace ID from query param
- const params = new URL(window.location.href).searchParams;
- const wsId = params.get('ws') || '';
-
- // Load workspace name
- let wsName = 'Editor';
- if (wsId && typeof API !== 'undefined') {
- try {
- const ws = await API._get('/api/v1/workspaces/' + wsId);
- wsName = ws?.name || ws?.data?.name || wsName;
- } catch (_) {}
- }
-
- // Build topbar
- const topbar = _buildTopbar(wsName);
- surface.appendChild(topbar);
-
- // Mount user menu via SDK (flyout drops down from topbar)
- if (typeof sw !== 'undefined' && sw.userMenu) {
- sw.userMenu(topbar, { flyout: 'down' });
- }
-
- // Build body + bootstrap
- const body = document.createElement('div');
- body.className = 'ext-editor-body';
- body.id = 'editorBody';
- surface.appendChild(body);
-
- const bootstrap = _buildBootstrap();
- surface.appendChild(bootstrap);
-
- // Load dynamic dependencies (codemirror only — ChatPane, NotePanel, note-graph are platform scripts in base.html)
- const ver = window.__VERSION__ || '';
- const verQ = ver ? '?v=' + ver : '';
- await _loadScript('/vendor/codemirror/codemirror.bundle.js' + verQ);
-
- _initWsSelector(wsId);
-
- if (!wsId) {
- body.style.display = 'none';
- bootstrap.style.display = '';
- _loadBootstrapList();
- _initBootstrapCreate();
- return;
- }
-
- _mountEditor(wsId, wsName);
- }
-
- // ── Topbar ──────────────────────────────────
-
- function _buildTopbar(wsName) {
- const el = document.createElement('div');
- el.className = 'ext-editor-topbar';
- el.id = 'editorTopbar';
- el.innerHTML =
- '' +
- ' ' +
- 'Back' +
- ' ' +
- '
' +
- ' ' +
- '' +
- '
' +
- '' + esc(wsName || 'Editor') + ' ' +
- ' ' +
- ' ' +
- '
' +
- '
' +
- '
' +
- '
' +
- ' ' +
- 'New Workspace' +
- ' ' +
- '
' +
- '
' +
- '' +
- ' ' +
- 'main ' +
- '
' +
- '
' +
- '' +
- ' ' +
- ' ';
- return el;
- }
-
- // ── Bootstrap (no workspace) ────────────────
-
- function _buildBootstrap() {
- const el = document.createElement('div');
- el.className = 'ext-editor-bootstrap';
- el.id = 'editorBootstrap';
- el.style.display = 'none';
- el.innerHTML =
- '' +
- '
' +
- ' ' +
- ' ' +
- '
Open a Workspace ' +
- '
' +
- '
Loading workspaces\u2026
' +
- '
' +
- '
' +
- '
' +
- '
or create new ' +
- '
' +
- '
' +
- '
' +
- '
Create Workspace ' +
- '
';
- return el;
- }
-
- // ── Workspace Selector ──────────────────────
-
- function _initWsSelector(currentWsId) {
- const btn = document.getElementById('editorWsSelectorBtn');
- const dropdown = document.getElementById('editorWsDropdown');
- if (!btn || !dropdown) return;
-
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- if (dropdown.classList.toggle('open')) _loadWsDropdown(currentWsId);
- });
- document.addEventListener('click', (e) => {
- if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
- });
- document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
- dropdown.classList.remove('open');
- const name = window.prompt('Workspace name:');
- if (!name) return;
- try {
- const userId = sw.user?.id;
- if (!userId) throw new Error('Not authenticated');
- const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
- const newId = resp.id || resp.data?.id;
- if (newId) window.location.href = base + '/s/editor?ws=' + newId;
- } catch (e) {
- if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
- }
- });
- }
-
- async function _loadWsDropdown(currentWsId) {
- const listEl = document.getElementById('editorWsList');
- if (!listEl) return;
- listEl.innerHTML = 'Loading\u2026
';
- try {
- const resp = await API._get('/api/v1/workspaces');
- const workspaces = resp.data || resp || [];
- listEl.innerHTML = '';
- if (!workspaces.length) {
- listEl.innerHTML = 'No workspaces
';
- return;
- }
- workspaces.forEach(ws => {
- const item = document.createElement('button');
- item.className = 'ext-editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
- item.textContent = ws.name || ws.id?.slice(0, 8);
- item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
- listEl.appendChild(item);
- });
- } catch (_) {
- listEl.innerHTML = 'Failed to load
';
- }
- }
-
- // ── Bootstrap ────────────────────────────────
-
- async function _loadBootstrapList() {
- const listEl = document.getElementById('editorBootstrapList');
- if (!listEl) return;
- try {
- const resp = await API._get('/api/v1/workspaces');
- const workspaces = resp.data || resp || [];
- if (!workspaces.length) {
- listEl.innerHTML = 'No workspaces yet
';
- return;
- }
- listEl.innerHTML = '';
- workspaces.forEach(ws => {
- const item = document.createElement('button');
- item.className = 'ext-editor-bootstrap-ws-item';
- item.innerHTML =
- '' + esc(ws.name || ws.id?.slice(0, 8)) + ' ' +
- '' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + ' ';
- item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
- listEl.appendChild(item);
- });
- } catch (_) {
- listEl.innerHTML = 'Failed to load workspaces
';
- }
- }
-
- function _initBootstrapCreate() {
- const btn = document.getElementById('editorBootstrapBtn');
- const input = document.getElementById('editorBootstrapName');
- if (!btn || !input) return;
- btn.addEventListener('click', async () => {
- const name = input.value.trim() || 'workspace';
- btn.disabled = true;
- btn.textContent = 'Creating\u2026';
- try {
- const userId = sw.user?.id;
- if (!userId) throw new Error('Not authenticated');
- const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
- const newId = resp.id || resp.data?.id;
- if (!newId) throw new Error('No workspace ID returned');
- window.location.href = base + '/s/editor?ws=' + newId;
- } catch (e) {
- btn.disabled = false;
- btn.textContent = 'Create Workspace';
- if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
- }
- });
- }
-
- // ── Mount Pane Layout ───────────────────────
-
- function _mountEditor(wsId, wsName) {
- const body = document.getElementById('editorBody');
- if (!body) return;
-
- // ── Layout via SDK ──
- const layout = sw.layout(body, 'editor', { workspaceId: wsId });
- if (!layout) return;
-
- const filesPaneEl = layout._panes.get('files')?.el;
- const editorPaneEl = layout._panes.get('editor')?.el;
- const assistPaneInfo = layout._panes.get('assist');
-
- // ── FileTree via SDK ──
- let fileTree;
- if (filesPaneEl) {
- fileTree = sw.fileTree(filesPaneEl, {
- id: PREFIX, workspaceId: wsId,
- onSelect: (path) => { codeEditor?.openFile(path); _saveState(wsId, codeEditor); },
- onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
- onNewFile: () => _createNewFile(wsId, fileTree),
- });
- layout._panes.get('files').component = fileTree;
- }
-
- // ── CodeEditor via SDK ──
- let codeEditor;
- if (editorPaneEl) {
- codeEditor = sw.codeEditor(editorPaneEl, {
- id: PREFIX, workspaceId: wsId,
- onSave: () => fileTree?.refresh(),
- onActivate: (path) => { fileTree?.setActiveFile(path); _saveState(wsId, codeEditor); },
- });
- layout._panes.get('editor').component = codeEditor;
- }
-
- // ── Assist pane (tabbed: chat + notes) via SDK ──
- if (assistPaneInfo?.tabs) {
- // Chat tab — standalone mode handles everything (streaming, model selector, history)
- const chatPanel = assistPaneInfo.getTabPanel('chat');
- if (chatPanel) {
- const chatPane = sw.chat(chatPanel, {
- id: PREFIX,
- standalone: true,
- getContext: () => _getFileContext(codeEditor),
- });
- if (chatPane) {
- const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
- if (chatTab) chatTab.instance = chatPane;
- }
- }
-
- // Notes tab
- const notesPanel = assistPaneInfo.getTabPanel('notes');
- if (notesPanel) {
- const notePanel = sw.notes(notesPanel, { projectId: null });
- if (notePanel) {
- const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
- if (notesTab) {
- notesTab.instance = notePanel;
- const _loadNotes = () => {
- if (!notesTab._loaded) {
- notesTab._loaded = true;
- notePanel.loadNotesList();
- notePanel.loadNoteFolders();
- }
- };
- notesTab.btn.addEventListener('click', _loadNotes);
- if (notesTab._activated) _loadNotes();
- }
- }
- }
- }
-
- // Git branch
- _refreshGitBranch(wsId, codeEditor);
-
- // Toolbar
- document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
- fileTree?.refresh();
- _refreshGitBranch(wsId, codeEditor);
- });
-
- // Initial load
- fileTree?.refresh();
- _restoreState(wsId, codeEditor);
-
- console.log('[EditorPkg] Mounted for workspace', wsId);
- }
-
- // ── File Operations ─────────────────────────
-
- async function _deleteFile(wsId, path, fileTree, codeEditor) {
- const ok = typeof sw !== 'undefined' && sw.confirm
- ? await sw.confirm('Delete ' + path + '?', { destructive: true })
- : window.confirm('Delete ' + path + '?');
- if (!ok) return;
- try {
- await API.deleteWorkspaceFile(wsId, path);
- if (codeEditor?.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
- fileTree?.refresh();
- } catch (e) {
- if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
- }
- }
-
- async function _createNewFile(wsId, fileTree) {
- const name = window.prompt('File name (e.g. src/main.go):');
- if (!name) return;
- try {
- await API.writeWorkspaceFile(wsId, name.trim(), '');
- fileTree?.refresh();
- if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
- } catch (e) {
- if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
- }
- }
-
- async function _refreshGitBranch(wsId, codeEditor) {
- try {
- const resp = await API.getWorkspaceGitBranches(wsId);
- const branch = resp.current || null;
- if (branch) {
- const badge = document.getElementById('editorBranchBadge');
- const name = document.getElementById('editorBranchName');
- if (badge) badge.style.display = '';
- if (name) name.textContent = branch;
- }
- if (codeEditor) codeEditor.setBranch(branch);
- } catch (_) {}
- }
-
- // ── File Context (editor-specific, passed to ChatPane via getContext) ──
-
- function _getFileContext(editor) {
- if (!editor) return null;
- try {
- const openFiles = editor.getOpenFiles();
- if (!openFiles.length) return null;
- const activeTab = document.querySelector('.code-editor-tab.active');
- const path = activeTab?.dataset?.path || openFiles[0];
- const inst = CodeEditor._instances?.get(PREFIX);
- if (inst?._files) {
- const file = inst._files.get(path);
- if (file?.view) return { path, content: file.view.state.doc.toString().slice(0, 4000) };
- }
- } catch (_) {}
- return null;
- }
-
- // ── State Persistence (localStorage) ─────────
-
- const STATE_KEY_PREFIX = 'sb:editor:state:';
- let _stateSaveTimer = null;
-
- function _stateKey(wsId) {
- return STATE_KEY_PREFIX + (sw?.user?.id || '') + ':' + wsId;
- }
-
- function _restoreState(wsId, codeEditor) {
- if (!wsId) return;
- try {
- const raw = localStorage.getItem(_stateKey(wsId));
- if (!raw) return;
- const state = JSON.parse(raw);
- if (state.open_tabs && Array.isArray(state.open_tabs)) {
- for (const path of state.open_tabs) codeEditor.openFile(path);
- if (state.active_tab) codeEditor.activateFile(state.active_tab);
- }
- } catch (_) {}
- }
-
- function _saveState(wsId, codeEditor) {
- if (_stateSaveTimer) clearTimeout(_stateSaveTimer);
- _stateSaveTimer = setTimeout(() => {
- if (!wsId) return;
- try {
- const openFiles = codeEditor.getOpenFiles();
- const activeTab = document.querySelector('.code-editor-tab.active');
- localStorage.setItem(_stateKey(wsId), JSON.stringify({
- open_tabs: openFiles,
- active_tab: activeTab?.dataset?.path || '',
- updated_at: new Date().toISOString(),
- }));
- } catch (_) {}
- }, STATE_DEBOUNCE_MS);
- }
-
- // ── Boot ─────────────────────────────────────
-
- document.addEventListener('DOMContentLoaded', _init);
-})();
diff --git a/packages/editor/manifest.json b/packages/editor/manifest.json
deleted file mode 100644
index a641068..0000000
--- a/packages/editor/manifest.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "id": "editor",
- "title": "Editor",
- "type": "full",
- "version": "0.31.0",
- "tier": "browser",
- "author": "Armature",
- "icon": "✏️",
- "description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
- "requires": ["legacy-sdk"],
- "route": "/s/editor",
- "layout": "editor",
- "permissions": [],
- "settings": [
- { "key": "font_size", "label": "Font Size", "type": "number", "default": 13 },
- { "key": "tab_size", "label": "Tab Size", "type": "number", "default": 4 },
- { "key": "word_wrap", "label": "Word Wrap", "type": "boolean", "default": false }
- ]
-}
diff --git a/packages/git-board/css/main.css b/packages/git-board/css/main.css
deleted file mode 100644
index 4fa0cfa..0000000
--- a/packages/git-board/css/main.css
+++ /dev/null
@@ -1,450 +0,0 @@
-/* Git Board — Surface Styles */
-
-.ext-git-board-shell {
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
-}
-
-/* ── Header ──────────────────────────────── */
-
-.ext-git-board-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--sp-3) var(--sp-4);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
-}
-
-.ext-git-board-header__left,
-.ext-git-board-header__right {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
-}
-
-.ext-git-board-title {
- font-size: 18px;
- font-weight: 600;
- color: var(--text);
- margin: 0;
-}
-
-.ext-git-board-repo-picker {
- background: var(--bg-raised);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-family: var(--mono);
- font-size: 13px;
- padding: 5px var(--sp-2);
- max-width: 260px;
-}
-
-/* ── Connection Setup ─────────────────────── */
-
-.ext-git-board-setup {
- max-width: 480px;
- margin: 60px auto;
- text-align: center;
- padding: 0 var(--sp-4);
-}
-
-.ext-git-board-setup h2 {
- color: var(--text);
- font-size: 20px;
- margin: 0 0 var(--sp-2);
-}
-
-.ext-git-board-setup p {
- color: var(--text-2);
- font-size: 14px;
- line-height: 1.5;
- margin: 0 0 var(--sp-5);
-}
-
-
-.ext-git-board-setup__hint {
- font-size: 12px;
- color: var(--text-3);
-}
-
-/* ── Kanban Board ────────────────────────── */
-
-.ext-git-board-board {
- display: flex;
- gap: var(--sp-3);
- padding: var(--sp-3) var(--sp-4);
- flex: 1;
- overflow-x: auto;
- overflow-y: hidden;
-}
-
-.ext-git-board-column {
- min-width: 260px;
- max-width: 320px;
- flex: 1;
- display: flex;
- flex-direction: column;
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- overflow: hidden;
-}
-
-.ext-git-board-column__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--sp-3) var(--sp-3);
- border-bottom: 1px solid var(--border);
-}
-
-.ext-git-board-column__title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text);
- text-transform: uppercase;
- letter-spacing: 0.03em;
-}
-
-.ext-git-board-column__count {
- background: var(--bg-raised);
- color: var(--text-2);
- font-size: 11px;
- font-weight: 600;
- padding: 2px 7px;
- border-radius: var(--radius-lg);
-}
-
-.ext-git-board-column__cards {
- flex: 1;
- overflow-y: auto;
- padding: var(--sp-2);
- display: flex;
- flex-direction: column;
- gap: var(--sp-2);
-}
-
-/* ── Cards ───────────────────────────────── */
-
-.ext-git-board-card {
- display: block;
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: var(--sp-3);
- text-decoration: none;
- color: var(--text);
- transition: border-color var(--transition), background var(--transition);
- cursor: pointer;
-}
-
-.ext-git-board-card:hover {
- border-color: var(--accent);
- background: var(--bg-hover);
-}
-
-.ext-git-board-card--pr {
- border-left: 3px solid var(--accent);
-}
-
-.ext-git-board-card__header {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin-bottom: var(--sp-1);
-}
-
-.ext-git-board-card__number {
- font-family: var(--mono);
- font-size: 12px;
- color: var(--text-3);
-}
-
-.ext-git-board-card__assignee {
- font-size: 11px;
- color: var(--accent);
- margin-left: auto;
-}
-
-.ext-git-board-card__branch {
- font-family: var(--mono);
- font-size: 11px;
- color: var(--text-3);
- margin-left: auto;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 150px;
-}
-
-.ext-git-board-card__title {
- font-size: 13px;
- font-weight: 500;
- line-height: 1.4;
- color: var(--text);
-}
-
-.ext-git-board-card__labels {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-1);
- margin-top: var(--sp-2);
-}
-
-.ext-git-board-card__labels .ext-git-board-badge {
- font-size: 10px;
- padding: 1px var(--sp-2);
-}
-
-.ext-git-board-card__meta {
- display: flex;
- justify-content: space-between;
- margin-top: var(--sp-2);
- font-size: 11px;
- color: var(--text-3);
-}
-
-/* ── DnD States ─────────────────────────── */
-
-.ext-git-board-card[draggable="true"] {
- cursor: grab;
- user-select: none;
-}
-.ext-git-board-card[draggable="true"]:active {
- cursor: grabbing;
- opacity: 0.6;
-}
-.ext-git-board-column--dragover {
- border-color: var(--accent);
- background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
-}
-.ext-git-board-column--dragover .ext-git-board-column__header {
- border-bottom-color: var(--accent);
-}
-
-/* ── Empty state ─────────────────────────── */
-
-.ext-git-board-empty {
- display: flex;
- align-items: center;
- justify-content: center;
- flex: 1;
- color: var(--text-3);
- font-size: 14px;
-}
-
-/* ── Issue Detail Modal ─────────────────── */
-
-.ext-git-board-modal-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,0.5);
- z-index: 1000;
- display: flex;
- align-items: flex-start;
- justify-content: center;
- padding: var(--sp-10) var(--sp-4);
- overflow-y: auto;
-}
-
-.ext-git-board-modal {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- width: 100%;
- max-width: 680px;
- max-height: calc(100vh - 80px);
- max-height: calc(100dvh - 80px);
- display: flex;
- flex-direction: column;
- box-shadow: 0 8px 32px rgba(0,0,0,0.3);
-}
-
-.ext-git-board-modal__header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- padding: var(--sp-4) var(--sp-5);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
-}
-
-.ext-git-board-modal__title-row {
- display: flex;
- align-items: baseline;
- gap: var(--sp-2);
- flex: 1;
- min-width: 0;
-}
-
-.ext-git-board-modal__title {
- font-size: 18px;
- font-weight: 600;
- color: var(--text);
- margin: 0;
- word-break: break-word;
-}
-
-.ext-git-board-modal__close {
- background: none;
- border: none;
- color: var(--text-3);
- font-size: 18px;
- cursor: pointer;
- padding: 2px var(--sp-2);
- border-radius: var(--radius);
- flex-shrink: 0;
-}
-.ext-git-board-modal__close:hover {
- color: var(--text);
- background: var(--bg-hover);
-}
-
-.ext-git-board-modal__body {
- overflow-y: auto;
- padding: var(--sp-4) var(--sp-5);
- flex: 1;
- min-height: 0;
-}
-
-.ext-git-board-modal__meta {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- flex-wrap: wrap;
- margin-bottom: var(--sp-3);
- font-size: 12px;
- color: var(--text-2);
-}
-
-.ext-git-board-modal__date {
- color: var(--text-3);
-}
-
-.ext-git-board-modal__extlink {
- margin-left: auto;
- color: var(--accent);
- text-decoration: none;
- font-size: 12px;
-}
-.ext-git-board-modal__extlink:hover {
- text-decoration: underline;
-}
-
-.ext-git-board-modal__description {
- margin-bottom: var(--sp-5);
- padding-bottom: var(--sp-4);
- border-bottom: 1px solid var(--border);
-}
-
-.ext-git-board-modal__body-text {
- font-family: var(--font);
- font-size: 13px;
- line-height: 1.6;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
- margin: 0;
- background: none;
- border: none;
- padding: 0;
-}
-
-.ext-git-board-modal__empty {
- color: var(--text-3);
- font-size: 13px;
- font-style: italic;
- margin: 0;
-}
-
-.ext-git-board-modal__section-title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-2);
- text-transform: uppercase;
- letter-spacing: 0.03em;
- margin: 0 0 var(--sp-3);
-}
-
-/* ── Comments ────────────────────────────── */
-
-.ext-git-board-comment {
- padding: var(--sp-3) 0;
- border-bottom: 1px solid var(--border);
-}
-.ext-git-board-comment:last-child {
- border-bottom: none;
-}
-
-.ext-git-board-comment__header {
- display: flex;
- align-items: baseline;
- gap: var(--sp-2);
- margin-bottom: var(--sp-1);
- font-size: 12px;
-}
-.ext-git-board-comment__header strong {
- color: var(--accent);
-}
-
-.ext-git-board-comment__date {
- color: var(--text-3);
- font-size: 11px;
-}
-
-.ext-git-board-comment__body {
- font-size: 13px;
- line-height: 1.5;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-/* ── Add Comment ─────────────────────────── */
-
-.ext-git-board-modal__add-comment {
- margin-top: var(--sp-4);
- padding-top: var(--sp-4);
- border-top: 1px solid var(--border);
-}
-
-.ext-git-board-modal__textarea {
- width: 100%;
- background: var(--bg-raised);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-family: var(--font);
- font-size: 13px;
- padding: var(--sp-2) var(--sp-3);
- resize: vertical;
- outline: none;
- box-sizing: border-box;
-}
-.ext-git-board-modal__textarea:focus {
- border-color: var(--accent);
-}
-
-.ext-git-board-modal__actions {
- display: flex;
- gap: var(--sp-2);
- margin-top: var(--sp-2);
- justify-content: flex-end;
-}
-
-/* ── Badge variants ──────────────────────── */
-
-.ext-git-board-badge--green {
- background: var(--green);
- color: #fff;
-}
-.ext-git-board-badge--muted {
- background: var(--bg-raised);
- color: var(--text-3);
-}
-
-/* git-board danger button override — uses kernel .sw-btn--danger */
diff --git a/packages/git-board/js/main.js b/packages/git-board/js/main.js
deleted file mode 100644
index 15f5131..0000000
--- a/packages/git-board/js/main.js
+++ /dev/null
@@ -1,491 +0,0 @@
-/**
- * Git Board — Surface Entry Point
- *
- * Kanban board for Gitea issues and PRs.
- * Calls Starlark backend via /s/git-board/api/*.
- * Authentication via gitea-client library connections.
- *
- * Features: DnD between columns, issue detail modal with comments.
- */
-(async function () {
- 'use strict';
-
- var mount = document.getElementById('extension-mount');
- if (!mount) return;
-
- var base = window.__BASE__ || '';
- var ver = window.__VERSION__ || '0';
- var slug = 'git-board';
-
- // ── Boot SDK ───────────────────────────────
- try {
- if (!window.preact) {
- var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
- var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
- var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
- window.preact = { h, render };
- window.hooks = hooksModule;
- window.html = htmModule.default.bind(h);
- }
- var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
- await sdk.boot();
- } catch (e) {
- mount.innerHTML = 'SDK boot failed: ' + e.message + '
';
- return;
- }
-
- var { html } = window;
- var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
- var { render } = preact;
-
- var API = base + '/s/' + slug + '/api';
-
- async function api(path, opts) {
- var token = sw.auth._getToken();
- var fetchOpts = { headers: { 'Authorization': 'Bearer ' + token } };
- if (opts && opts.method) fetchOpts.method = opts.method;
- if (opts && opts.body) {
- fetchOpts.body = JSON.stringify(opts.body);
- fetchOpts.headers['Content-Type'] = 'application/json';
- }
- var resp = await fetch(API + path, fetchOpts);
- var text = await resp.text();
- try { return JSON.parse(text); } catch (_) { return { error: text }; }
- }
-
- // ── App ────────────────────────────────────
-
- function App() {
- var [owner, setOwner] = useState('');
- var [repo, setRepo] = useState('');
- var [repos, setRepos] = useState([]);
- var [board, setBoard] = useState(null);
- var [loading, setLoading] = useState(false);
- var [needsConn, setNeedsConn] = useState(false);
- var [modal, setModal] = useState(null); // {owner, repo, number}
- var menuRef = useRef(null);
-
- useEffect(function () {
- if (menuRef.current && sw.userMenu) {
- sw.userMenu(menuRef.current, { placement: 'down-left' });
- }
- }, []);
-
- useEffect(function () {
- api('/repos').then(function (d) {
- if (d.error) {
- if (d.error.indexOf('no gitea connection configured') !== -1) {
- setNeedsConn(true);
- } else { sw.toast(d.error, 'error'); }
- return;
- }
- setRepos(d.data || []);
- if (d.data && d.data.length > 0 && !owner) {
- setOwner(d.data[0].owner);
- setRepo(d.data[0].name);
- }
- });
- }, []);
-
- var loadBoard = useCallback(function () {
- if (!owner || !repo) return;
- setLoading(true);
- api('/board?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo))
- .then(function (d) {
- if (d.error) {
- sw.toast(d.error, 'error');
- if (d.error.indexOf('no gitea connection configured') !== -1) setNeedsConn(true);
- } else {
- setBoard(d);
- setNeedsConn(false);
- }
- setLoading(false);
- });
- }, [owner, repo]);
-
- useEffect(function () { loadBoard(); }, [loadBoard]);
-
- var onDrop = useCallback(function (issueNumber, targetCol) {
- if (!board) return;
- var issue = (board.issues || []).find(function (i) { return i.number === issueNumber; });
- if (!issue) return;
-
- var patch = {};
- if (targetCol === 'open') {
- // Move to Open: unassign + reopen
- if (issue.state === 'closed') patch.state = 'open';
- // Note: Gitea PATCH /issues doesn't support clearing assignee via empty string,
- // but we do our best — the board will re-split on refresh.
- } else if (targetCol === 'in_progress') {
- // Move to In Progress: assign to current user + ensure open
- if (issue.state === 'closed') patch.state = 'open';
- // Gitea needs assignees array — not supported by our simple update_issue.
- // For now, just reopen. User assigns via modal.
- if (issue.state === 'closed') patch.state = 'open';
- } else if (targetCol === 'done') {
- patch.state = 'closed';
- }
-
- if (!patch.state) return; // no meaningful change
-
- // Optimistic update
- var newIssues = (board.issues || []).map(function (i) {
- if (i.number !== issueNumber) return i;
- var copy = {};
- for (var k in i) copy[k] = i[k];
- if (patch.state) copy.state = patch.state;
- return copy;
- });
- setBoard({ issues: newIssues, pull_requests: board.pull_requests || [] });
-
- api('/issue/' + owner + '/' + repo + '/' + issueNumber, {
- method: 'POST', body: patch
- }).then(function (d) {
- if (d.error) {
- sw.toast('Update failed: ' + d.error, 'error');
- loadBoard(); // revert
- }
- });
- }, [board, owner, repo, loadBoard]);
-
- var openModal = useCallback(function (number) {
- setModal({ owner: owner, repo: repo, number: number });
- }, [owner, repo]);
-
- var closeModal = useCallback(function (changed) {
- setModal(null);
- if (changed) loadBoard();
- }, [loadBoard]);
-
- return html`
-
- ${needsConn ? html`<${ConnectionSetup} />` : html`
-
-
- ${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
-
${loading ? 'Loading…' : 'Select a repository'}
- `}
-
- `}
- ${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`}
- `;
- }
-
- // ── Connection Setup ─────────────────────────
-
- function ConnectionSetup() {
- return html`
-
-
-
-
Connect to Gitea
-
Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.
-
Ask your admin to add a Gitea connection in
- Admin → Connections , or add a personal one in
- Settings → Connections .
-
- Open Settings
-
-
Connections are managed centrally — no API tokens in extension settings.
-
-
- `;
- }
-
- // ── Repo Picker ────────────────────────────
-
- function RepoPicker({ repos, owner, repo, onSelect }) {
- var current = owner + '/' + repo;
- return html`
-
- ${repos.map(function (r) {
- return html`${r.full_name} `;
- })}
-
- `;
- }
-
- // ── Kanban Board with DnD ─────────────────
-
- function Board({ data, onDrop, onCardClick }) {
- var issues = data.issues || [];
- var prs = data.pull_requests || [];
-
- var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; });
- var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
-
- return html`
-
- <${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
- ${openUnassigned.map(function (i) {
- return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
- })}
- />
- <${Column} id="in_progress" title="In Progress" count=${inProgress.length} onDrop=${onDrop}>
- ${inProgress.map(function (i) {
- return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
- })}
- />
- <${Column} id="done" title="Done" count=${0} onDrop=${onDrop}>
- />
- <${Column} id="prs" title="Pull Requests" count=${prs.length}>
- ${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
- />
-
- `;
- }
-
- function Column({ id, title, count, children, onDrop }) {
- var [over, setOver] = useState(false);
-
- var handlers = onDrop ? {
- onDragOver: function (e) { e.preventDefault(); setOver(true); },
- onDragLeave: function () { setOver(false); },
- onDrop: function (e) {
- e.preventDefault();
- setOver(false);
- var num = parseInt(e.dataTransfer.getData('text/plain'), 10);
- if (num && onDrop) onDrop(num, id);
- }
- } : {};
-
- return html`
-
-
- ${title}
- ${count || 0}
-
-
${children}
-
- `;
- }
-
- function IssueCard({ issue, onClick }) {
- return html`
-
-
-
${esc(issue.title)}
-
- ${(issue.labels || []).map(function (l) {
- return html`${esc(l)} `;
- })}
-
-
- `;
- }
-
- function PRCard({ pr }) {
- return html`
-
-
- ${esc(pr.title)}
-
- @${esc(pr.user)}
- ${timeAgo(pr.created_at)}
-
-
- `;
- }
-
- // ── Issue Detail Modal ────────────────────
-
- function IssueModal({ owner, repo, number, onClose }) {
- var [issue, setIssue] = useState(null);
- var [loading, setLoading] = useState(true);
- var [comment, setComment] = useState('');
- var [posting, setPosting] = useState(false);
- var [changed, setChanged] = useState(false);
- var bodyRef = useRef(null);
-
- useEffect(function () {
- setLoading(true);
- api('/issue/' + owner + '/' + repo + '/' + number).then(function (d) {
- if (d.error) { sw.toast(d.error, 'error'); onClose(false); return; }
- setIssue(d);
- setLoading(false);
- });
- }, [owner, repo, number]);
-
- // Close on Escape
- useEffect(function () {
- var handler = function (e) { if (e.key === 'Escape') onClose(changed); };
- document.addEventListener('keydown', handler);
- return function () { document.removeEventListener('keydown', handler); };
- }, [changed]);
-
- var postComment = useCallback(function () {
- if (!comment.trim() || posting) return;
- setPosting(true);
- api('/issue/' + owner + '/' + repo + '/' + number + '/comment', {
- method: 'POST', body: { body: comment.trim() }
- }).then(function (d) {
- if (d.error) { sw.toast(d.error, 'error'); }
- else {
- // Append to local comments
- setIssue(function (prev) {
- if (!prev) return prev;
- var copy = {};
- for (var k in prev) copy[k] = prev[k];
- copy.comments = (prev.comments || []).concat([d]);
- return copy;
- });
- setComment('');
- setChanged(true);
- }
- setPosting(false);
- });
- }, [comment, posting, owner, repo, number]);
-
- var toggleState = useCallback(function () {
- if (!issue) return;
- var newState = issue.state === 'open' ? 'closed' : 'open';
- api('/issue/' + owner + '/' + repo + '/' + number, {
- method: 'POST', body: { state: newState }
- }).then(function (d) {
- if (d.error) { sw.toast(d.error, 'error'); return; }
- setIssue(function (prev) {
- if (!prev) return prev;
- var copy = {};
- for (var k in prev) copy[k] = prev[k];
- copy.state = newState;
- return copy;
- });
- setChanged(true);
- });
- }, [issue, owner, repo, number]);
-
- return html`
-
-
-
-
- ${!loading && issue && html`
-
-
-
- ${issue.labels && issue.labels.length > 0 && html`
-
- ${issue.labels.map(function (l) { return html`${esc(l)} `; })}
-
- `}
-
-
- ${issue.body ? html`
${esc(issue.body)} `
- : html`
No description.
`}
-
-
-
-
-
-
- `}
-
-
- `;
- }
-
- // ── Utilities ──────────────────────────────
-
- function esc(s) {
- var el = document.createElement('span');
- el.textContent = String(s || '');
- return el.innerHTML;
- }
-
- function timeAgo(iso) {
- if (!iso) return '';
- var sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
- if (sec < 60) return 'now';
- var min = Math.floor(sec / 60);
- if (min < 60) return min + 'm';
- var hr = Math.floor(min / 60);
- if (hr < 24) return hr + 'h';
- return Math.floor(hr / 24) + 'd';
- }
-
- // ── Mount ──────────────────────────────────
- render(html`<${App} />`, mount);
- console.log('[git-board] Surface mounted');
-})();
diff --git a/packages/git-board/manifest.json b/packages/git-board/manifest.json
deleted file mode 100644
index d855f2a..0000000
--- a/packages/git-board/manifest.json
+++ /dev/null
@@ -1,114 +0,0 @@
-{
- "id": "git-board",
- "title": "Git Board",
- "type": "full",
- "tier": "starlark",
- "route": "/s/git-board",
- "auth": "authenticated",
- "layout": "single",
- "version": "0.2.0",
- "icon": "🔀",
- "description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
- "author": "armature",
-
- "permissions": ["connections.read"],
-
- "dependencies": {
- "gitea-client": ">=1.0.0"
- },
-
- "api_routes": [
- {"method": "GET", "path": "/repos"},
- {"method": "GET", "path": "/board"},
- {"method": "GET", "path": "/issue/*"},
- {"method": "POST", "path": "/issue/*"},
- {"method": "GET", "path": "/ci/*"}
- ],
-
- "tools": [
- {
- "name": "list_issues",
- "description": "List issues for a repository. Returns titles, numbers, labels, assignees, and state.",
- "parameters": {
- "owner": {"type": "string", "required": true, "description": "Repository owner or org"},
- "repo": {"type": "string", "required": true, "description": "Repository name"},
- "state": {"type": "string", "required": false, "description": "Filter: open (default), closed, all"}
- }
- },
- {
- "name": "get_issue",
- "description": "Get full details for a single issue including body and comments.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "number": {"type": "number", "required": true, "description": "Issue number"}
- }
- },
- {
- "name": "create_issue",
- "description": "Create a new issue in a repository.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "title": {"type": "string", "required": true},
- "body": {"type": "string", "required": false},
- "labels": {"type": "string", "required": false, "description": "Comma-separated label names"}
- }
- },
- {
- "name": "update_issue",
- "description": "Update an existing issue (title, body, state, labels).",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "number": {"type": "number", "required": true},
- "title": {"type": "string", "required": false},
- "body": {"type": "string", "required": false},
- "state": {"type": "string", "required": false, "description": "open or closed"}
- }
- },
- {
- "name": "add_comment",
- "description": "Add a comment to an issue or pull request.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "number": {"type": "number", "required": true},
- "body": {"type": "string", "required": true}
- }
- },
- {
- "name": "list_pull_requests",
- "description": "List pull requests for a repository with status, branch, and review state.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "state": {"type": "string", "required": false, "description": "open (default), closed, all"}
- }
- },
- {
- "name": "get_ci_status",
- "description": "Get CI/CD job status for a commit or branch reference.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "ref": {"type": "string", "required": true, "description": "Branch name, tag, or commit SHA"}
- }
- }
- ],
-
- "settings": {
- "default_owner": {
- "type": "string",
- "label": "Default Owner/Org",
- "description": "Default repository owner for the board view.",
- "default": ""
- },
- "default_repo": {
- "type": "string",
- "label": "Default Repository",
- "description": "Default repository name for the board view.",
- "default": ""
- }
- }
-}
diff --git a/packages/git-board/script.star b/packages/git-board/script.star
deleted file mode 100644
index ba66282..0000000
--- a/packages/git-board/script.star
+++ /dev/null
@@ -1,205 +0,0 @@
-# Git Board — Starlark Backend (v0.2.0)
-#
-# Library consumer — delegates all Gitea API work to gitea-client.
-#
-# Entry points:
-# on_request(req) → surface API routes
-# on_tool_call(tool_name, params) → LLM tool execution
-#
-# Modules:
-# connections — resolve gitea connection (connections.read)
-# lib — load gitea-client library
-# json — encode/decode (universal)
-# settings — user: default_owner, default_repo
-
-gitea = lib.require("gitea-client")
-
-
-def _conn():
- """Resolve the first available gitea connection."""
- return connections.get("gitea")
-
-
-# ═══════════════════════════════════════════════
-# Surface API routes (/s/git-board/api/*)
-# ═══════════════════════════════════════════════
-
-def on_request(req):
- path = req["path"]
- method = req["method"]
-
- conn = _conn()
- if conn == None:
- return _resp(400, {"error": "no gitea connection configured — add one in Settings > Connections"})
-
- if method == "GET" and path == "/repos":
- return _handle_repos(conn)
- elif method == "GET" and path == "/board":
- return _handle_board(conn, req)
- elif method == "GET" and path.startswith("/issue/"):
- return _handle_get_issue(conn, path[len("/issue/"):])
- elif method == "POST" and path.startswith("/issue/"):
- return _handle_post_issue(conn, path[len("/issue/"):], req)
- elif method == "GET" and path.startswith("/ci/"):
- return _handle_ci(conn, path[len("/ci/"):])
-
- return _resp(404, {"error": "not found"})
-
-
-def _handle_repos(conn):
- """List repositories accessible via the connection."""
- repos = gitea.get_repos(conn)
- if repos == None:
- return _resp(502, {"error": "gitea request failed"})
- return _resp(200, {"data": repos})
-
-
-def _handle_board(conn, req):
- """Combined issues + PRs for kanban board."""
- q = req.get("query", {})
- owner = _str(q.get("owner", "")) or settings.get("default_owner") or ""
- repo = _str(q.get("repo", "")) or settings.get("default_repo") or ""
- if not owner or not repo:
- return _resp(400, {"error": "owner and repo required (query params or user settings)"})
-
- issues = gitea.get_issues(conn, owner, repo, "open") or []
- prs = gitea.get_prs(conn, owner, repo, "open") or []
-
- board = {"issues": issues, "pull_requests": prs}
- return _resp(200, board)
-
-
-def _handle_get_issue(conn, issue_path):
- """Get issue detail: /issue/:owner/:repo/:number"""
- parts = issue_path.split("/", 2)
- if len(parts) < 3:
- return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
- owner, repo, num = parts[0], parts[1], int(parts[2])
- data = gitea.get_issue(conn, owner, repo, num)
- if data == None:
- return _resp(404, {"error": "issue not found"})
- return _resp(200, data)
-
-
-def _handle_post_issue(conn, issue_path, req):
- """Update issue or add comment: /issue/:owner/:repo/:number[/comment]"""
- parts = issue_path.split("/", 3)
- if len(parts) < 3:
- return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
- owner, repo, num = parts[0], parts[1], int(parts[2])
- action = parts[3] if len(parts) > 3 else ""
-
- body = json.decode(req.get("body", "{}"))
-
- if action == "comment":
- text = body.get("body", "")
- if not text:
- return _resp(400, {"error": "comment body required"})
- result = gitea.add_comment(conn, owner, repo, num, text)
- if result == None:
- return _resp(502, {"error": "failed to add comment"})
- return _resp(201, result)
-
- # Default: update issue fields (state, title, body, assignee)
- title = body.get("title", "")
- issue_body = body.get("body", "")
- state = body.get("state", "")
- result = gitea.update_issue(conn, owner, repo, num, title, issue_body, state)
- if result == None:
- return _resp(502, {"error": "failed to update issue"})
- return _resp(200, result)
-
-
-def _handle_ci(conn, ref_path):
- """CI status for owner/repo/ref."""
- parts = ref_path.split("/", 2)
- if len(parts) < 3:
- return _resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
- owner, repo, ref = parts[0], parts[1], parts[2]
- result = gitea.get_ci_status(conn, owner, repo, ref)
- if result == None:
- return _resp(502, {"error": "CI status request failed"})
- return _resp(200, result)
-
-
-# ═══════════════════════════════════════════════
-# LLM Tools (called by AI during chat)
-# ═══════════════════════════════════════════════
-
-def on_tool_call(tool_name, params):
- conn = _conn()
- if conn == None:
- return {"error": "no gitea connection configured — add one in Settings > Connections"}
-
- owner = params.get("owner", "")
- repo = params.get("repo", "")
-
- if tool_name == "list_issues":
- state = params.get("state", "open")
- data = gitea.get_issues(conn, owner, repo, state)
- if data == None:
- return {"error": "failed to list issues"}
- return {"issues": data, "count": len(data)}
-
- elif tool_name == "get_issue":
- num = int(params.get("number", 0))
- data = gitea.get_issue(conn, owner, repo, num)
- if data == None:
- return {"error": "issue not found"}
- return data
-
- elif tool_name == "create_issue":
- data = gitea.create_issue(conn, owner, repo,
- params.get("title", ""),
- params.get("body", ""),
- params.get("labels", ""))
- if data == None:
- return {"error": "failed to create issue"}
- return data
-
- elif tool_name == "update_issue":
- num = int(params.get("number", 0))
- data = gitea.update_issue(conn, owner, repo, num,
- params.get("title", ""),
- params.get("body", ""),
- params.get("state", ""))
- if data == None:
- return {"error": "failed to update issue"}
- return data
-
- elif tool_name == "add_comment":
- num = int(params.get("number", 0))
- data = gitea.add_comment(conn, owner, repo, num, params.get("body", ""))
- if data == None:
- return {"error": "failed to add comment"}
- return data
-
- elif tool_name == "list_pull_requests":
- state = params.get("state", "open")
- data = gitea.get_prs(conn, owner, repo, state)
- if data == None:
- return {"error": "failed to list PRs"}
- return {"pull_requests": data, "count": len(data)}
-
- elif tool_name == "get_ci_status":
- ref = params.get("ref", "")
- data = gitea.get_ci_status(conn, owner, repo, ref)
- if data == None:
- return {"error": "failed to get CI status"}
- return data
-
- return {"error": "unknown tool: " + tool_name}
-
-
-# ═══════════════════════════════════════════════
-# Response helpers
-# ═══════════════════════════════════════════════
-
-def _resp(status, data):
- return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
-
-def _str(v):
- """Coerce Starlark query param to string."""
- if v == None:
- return ""
- return str(v)
diff --git a/packages/gitea-client/manifest.json b/packages/gitea-client/manifest.json
deleted file mode 100644
index 2710bb1..0000000
--- a/packages/gitea-client/manifest.json
+++ /dev/null
@@ -1,72 +0,0 @@
-{
- "id": "gitea-client",
- "title": "Gitea API Client",
- "type": "library",
- "tier": "starlark",
- "version": "1.0.1",
- "description": "Shared Gitea client — connections, API helpers, optional data caching.",
- "author": "armature",
-
- "permissions": ["api.http", "connections.read", "db.read", "db.write"],
-
- "exports": [
- "get_repos", "get_issues", "get_issue", "create_issue",
- "update_issue", "add_comment", "get_prs", "get_ci_status"
- ],
-
- "api_routes": [
- {"method": "GET", "path": "/repos"},
- {"method": "GET", "path": "/issues"},
- {"method": "GET", "path": "/issues/*"},
- {"method": "POST", "path": "/issues"},
- {"method": "GET", "path": "/prs"},
- {"method": "GET", "path": "/ci/*"},
- {"method": "POST", "path": "/sync"}
- ],
-
- "connections": [
- {
- "type": "gitea",
- "label": "Gitea Instance",
- "fields": {
- "base_url": {"type": "url", "required": "true", "label": "Server URL"},
- "api_token": {"type": "secret", "required": "true", "label": "API Token"},
- "org": {"type": "string", "required": "false", "label": "Default Org"}
- },
- "scopes": ["global", "team", "personal"]
- }
- ],
-
- "db_tables": {
- "repos": {
- "columns": {
- "connection_id": "text",
- "full_name": "text",
- "owner": "text",
- "name": "text",
- "description": "text",
- "html_url": "text",
- "open_issues": "integer",
- "synced_at": "timestamp"
- },
- "indexes": [["connection_id"]]
- },
- "issues": {
- "columns": {
- "connection_id": "text",
- "repo_full_name": "text",
- "number": "integer",
- "title": "text",
- "state": "text",
- "labels": "text",
- "assignee": "text",
- "body": "text",
- "created_at": "text",
- "synced_at": "timestamp"
- },
- "indexes": [["connection_id", "repo_full_name"]]
- }
- },
-
- "schema_version": 1
-}
diff --git a/packages/gitea-client/script.star b/packages/gitea-client/script.star
deleted file mode 100644
index cca7b3e..0000000
--- a/packages/gitea-client/script.star
+++ /dev/null
@@ -1,186 +0,0 @@
-# Gitea API Client — Library Package
-#
-# Shared Gitea client providing connection-backed API helpers.
-# Consumers call lib.require("gitea-client") and pass a conn dict
-# obtained from connections.get("gitea").
-#
-# Entry points:
-# on_request(req) — REST API routes for browser-side consumers
-# Exported functions — Starlark consumers via lib.require()
-#
-# Modules available (via library permissions):
-# http — outbound HTTP (api.http)
-# connections — credential resolution (connections.read)
-# db — ext_gitea_client_* tables (db.read, db.write)
-# json — encode/decode (universal)
-
-load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp")
-load("star/repos.star", _repos_get = "get_repos")
-load("star/issues.star", _issues_get = "get_issues", _issue_get = "get_issue", _issue_create = "create_issue", _issue_update = "update_issue", _comment_add = "add_comment")
-load("star/ci.star", _ci_get = "get_ci_status")
-
-# Re-export loaded functions so lib.require() can find them in globals.
-# Starlark load() names are file-local; explicit assignment makes them global.
-get_repos = _repos_get
-get_issues = _issues_get
-get_issue = _issue_get
-create_issue = _issue_create
-update_issue = _issue_update
-add_comment = _comment_add
-get_ci_status = _ci_get
-
-
-# ═══════════════════════════════════════════════
-# REST API routes (/s/gitea-client/api/*)
-# ═══════════════════════════════════════════════
-
-def on_request(req):
- path = req["path"]
- method = req["method"]
-
- conn = _resolve_conn()
- if conn == None:
- return resp(400, {"error": "no gitea connection configured"})
-
- if method == "GET" and path == "/repos":
- repos = get_repos(conn)
- if repos == None:
- return resp(502, {"error": "gitea request failed"})
- return resp(200, {"data": repos})
-
- elif method == "GET" and path == "/issues":
- q = req.get("query", {})
- owner = _str(q.get("owner", ""))
- repo = _str(q.get("repo", ""))
- state = _str(q.get("state", "")) or "open"
- if not owner or not repo:
- return resp(400, {"error": "owner and repo query params required"})
- issues = get_issues(conn, owner, repo, state)
- if issues == None:
- return resp(502, {"error": "gitea request failed"})
- return resp(200, {"data": issues, "count": len(issues)})
-
- elif method == "GET" and path.startswith("/issues/"):
- parts = path[len("/issues/"):].split("/", 2)
- if len(parts) < 3:
- return resp(400, {"error": "path must be /issues/:owner/:repo/:number"})
- owner, repo, num = parts[0], parts[1], int(parts[2])
- issue = get_issue(conn, owner, repo, num)
- if issue == None:
- return resp(404, {"error": "issue not found"})
- return resp(200, issue)
-
- elif method == "POST" and path == "/issues":
- body = json.decode(req.get("body", "{}"))
- result = create_issue(conn, body.get("owner", ""), body.get("repo", ""),
- body.get("title", ""), body.get("body", ""),
- body.get("labels", ""))
- if result == None:
- return resp(502, {"error": "failed to create issue"})
- return resp(201, result)
-
- elif method == "GET" and path == "/prs":
- q = req.get("query", {})
- owner = _str(q.get("owner", ""))
- repo = _str(q.get("repo", ""))
- state = _str(q.get("state", "")) or "open"
- if not owner or not repo:
- return resp(400, {"error": "owner and repo query params required"})
- prs = get_prs(conn, owner, repo, state)
- if prs == None:
- return resp(502, {"error": "gitea request failed"})
- return resp(200, {"data": prs, "count": len(prs)})
-
- elif method == "GET" and path.startswith("/ci/"):
- parts = path[len("/ci/"):].split("/", 2)
- if len(parts) < 3:
- return resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
- owner, repo, ref = parts[0], parts[1], parts[2]
- result = get_ci_status(conn, owner, repo, ref)
- if result == None:
- return resp(502, {"error": "CI status request failed"})
- return resp(200, result)
-
- elif method == "POST" and path == "/sync":
- return _handle_sync(conn, req)
-
- return resp(404, {"error": "not found"})
-
-
-# ═══════════════════════════════════════════════
-# PR helper (not large enough for its own file)
-# ═══════════════════════════════════════════════
-
-def get_prs(conn, owner, repo, state):
- """List pull requests for a repository."""
- state = state or "open"
- path = "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50"
- data = gitea_get(conn, path)
- if data == None:
- return None
- items = []
- for p in data:
- items.append({
- "number": p.get("number", 0),
- "title": p.get("title", ""),
- "state": p.get("state", ""),
- "head": p.get("head", {}).get("ref", ""),
- "base": p.get("base", {}).get("ref", ""),
- "user": (p.get("user") or {}).get("login", ""),
- "created_at": p.get("created_at", ""),
- "html_url": p.get("html_url", ""),
- "mergeable": p.get("mergeable", None),
- })
- return items
-
-
-# ═══════════════════════════════════════════════
-# Sync handler — cache repos/issues to db tables
-# ═══════════════════════════════════════════════
-
-def _handle_sync(conn, req):
- """Sync repos and issues into local cache tables."""
- repos = get_repos(conn)
- if repos == None:
- return resp(502, {"error": "failed to fetch repos"})
-
- conn_id = conn.get("id", "default")
-
- # Clear and re-insert repos
- db.exec("DELETE FROM repos WHERE connection_id = ?", [conn_id])
- for r in repos:
- db.exec(
- "INSERT INTO repos (connection_id, full_name, owner, name, description, html_url, open_issues, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))",
- [conn_id, r["full_name"], r["owner"], r["name"], r.get("description", ""), r.get("html_url", ""), r.get("open_issues", 0)]
- )
-
- # Sync open issues for each repo
- issue_count = 0
- db.exec("DELETE FROM issues WHERE connection_id = ?", [conn_id])
- for r in repos:
- issues = get_issues(conn, r["owner"], r["name"], "open")
- if issues == None:
- continue
- for i in issues:
- labels_str = ",".join(i.get("labels", []))
- db.exec(
- "INSERT INTO issues (connection_id, repo_full_name, number, title, state, labels, assignee, body, created_at, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
- [conn_id, r["full_name"], i["number"], i["title"], i["state"], labels_str, i.get("assignee", ""), "", i.get("created_at", "")]
- )
- issue_count += 1
-
- return resp(200, {"repos": len(repos), "issues": issue_count})
-
-
-# ═══════════════════════════════════════════════
-# Helpers
-# ═══════════════════════════════════════════════
-
-def _resolve_conn():
- """Resolve the first available gitea connection."""
- return connections.get("gitea")
-
-def _str(v):
- if v == None:
- return ""
- return str(v)
diff --git a/packages/gitea-client/star/ci.star b/packages/gitea-client/star/ci.star
deleted file mode 100644
index 6a3f110..0000000
--- a/packages/gitea-client/star/ci.star
+++ /dev/null
@@ -1,25 +0,0 @@
-# gitea-client — CI status operations
-
-load("star/http_helpers.star", "gitea_get")
-
-def get_ci_status(conn, owner, repo, ref):
- """Get CI/CD job status for a commit or branch reference."""
- path = "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
- data = gitea_get(conn, path)
- if data == None:
- return None
- statuses = data.get("statuses", [])
- items = []
- for s in statuses:
- items.append({
- "context": s.get("context", s.get("name", "")),
- "state": s.get("state", s.get("status", "")),
- "description": s.get("description", ""),
- "target_url": s.get("target_url", ""),
- })
- return {
- "ref": ref,
- "state": data.get("state", ""),
- "statuses": items,
- "count": len(items),
- }
diff --git a/packages/gitea-client/star/http_helpers.star b/packages/gitea-client/star/http_helpers.star
deleted file mode 100644
index 09f3cf5..0000000
--- a/packages/gitea-client/star/http_helpers.star
+++ /dev/null
@@ -1,60 +0,0 @@
-# gitea-client — HTTP helpers
-#
-# Shared HTTP utilities for Gitea API calls.
-# All functions take a `conn` dict from connections.get("gitea").
-
-def auth_headers(conn):
- """Build authorization headers from a connection dict."""
- token = conn.get("api_token", "")
- if not token:
- return {}
- return {"Authorization": "token " + token}
-
-def _base(conn):
- """Return base URL with trailing slash stripped."""
- url = conn.get("base_url", "")
- if url.endswith("/"):
- url = url[:-1]
- return url
-
-def gitea_get(conn, path):
- """GET request to Gitea API. Returns decoded body or None."""
- url = _base(conn) + "/api/v1" + path
- resp = http.get(url=url, headers=auth_headers(conn))
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-def gitea_post(conn, path, data):
- """POST request to Gitea API. Returns decoded body or None."""
- url = _base(conn) + "/api/v1" + path
- hdrs = dict(auth_headers(conn), **{"Content-Type": "application/json"})
- resp = http.post(url=url, body=json.encode(data), headers=hdrs)
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-def gitea_patch(conn, path, data):
- """PATCH request to Gitea API (via X-HTTP-Method-Override). Returns decoded body or None."""
- url = _base(conn) + "/api/v1" + path
- hdrs = dict(auth_headers(conn), **{
- "Content-Type": "application/json",
- "X-HTTP-Method-Override": "PATCH",
- })
- resp = http.post(url=url, body=json.encode(data), headers=hdrs)
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-def resp(status, data):
- """Build a standard JSON response dict."""
- return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
diff --git a/packages/gitea-client/star/issues.star b/packages/gitea-client/star/issues.star
deleted file mode 100644
index 1d8a509..0000000
--- a/packages/gitea-client/star/issues.star
+++ /dev/null
@@ -1,94 +0,0 @@
-# gitea-client — Issue operations
-
-load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch")
-
-def get_issues(conn, owner, repo, state):
- """List issues for a repository."""
- state = state or "open"
- path = "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues"
- data = gitea_get(conn, path)
- if data == None:
- return None
- items = []
- for i in data:
- labels = [l.get("name", "") for l in (i.get("labels") or [])]
- items.append({
- "number": i.get("number", 0),
- "title": i.get("title", ""),
- "state": i.get("state", ""),
- "labels": labels,
- "assignee": (i.get("assignee") or {}).get("login", ""),
- "created_at": i.get("created_at", ""),
- "updated_at": i.get("updated_at", ""),
- "html_url": i.get("html_url", ""),
- })
- return items
-
-def get_issue(conn, owner, repo, number):
- """Get full issue details including comments."""
- path = "/repos/" + owner + "/" + repo + "/issues/" + str(number)
- data = gitea_get(conn, path)
- if data == None:
- return None
- comments_data = gitea_get(conn, path + "/comments") or []
- comments = []
- for c in comments_data:
- comments.append({
- "user": (c.get("user") or {}).get("login", ""),
- "body": c.get("body", ""),
- "created_at": c.get("created_at", ""),
- })
- return {
- "number": data.get("number", 0),
- "title": data.get("title", ""),
- "body": data.get("body", ""),
- "state": data.get("state", ""),
- "labels": [l.get("name", "") for l in (data.get("labels") or [])],
- "assignee": (data.get("assignee") or {}).get("login", ""),
- "created_at": data.get("created_at", ""),
- "comments": comments,
- }
-
-def create_issue(conn, owner, repo, title, body, labels):
- """Create a new issue."""
- path = "/repos/" + owner + "/" + repo + "/issues"
- payload = {"title": title}
- if body:
- payload["body"] = body
- if labels:
- payload["labels"] = [l.strip() for l in labels.split(",") if l.strip()]
- data = gitea_post(conn, path, payload)
- if data == None:
- return None
- return {
- "number": data.get("number", 0),
- "title": data.get("title", ""),
- "html_url": data.get("html_url", ""),
- }
-
-def update_issue(conn, owner, repo, number, title, body, state):
- """Update an existing issue."""
- path = "/repos/" + owner + "/" + repo + "/issues/" + str(number)
- patch = {}
- if title:
- patch["title"] = title
- if body:
- patch["body"] = body
- if state:
- patch["state"] = state
- data = gitea_patch(conn, path, patch)
- if data == None:
- return None
- return {
- "number": data.get("number", 0),
- "state": data.get("state", ""),
- "title": data.get("title", ""),
- }
-
-def add_comment(conn, owner, repo, number, body):
- """Add a comment to an issue."""
- path = "/repos/" + owner + "/" + repo + "/issues/" + str(number) + "/comments"
- data = gitea_post(conn, path, {"body": body})
- if data == None:
- return None
- return {"id": data.get("id", 0), "body": data.get("body", "")}
diff --git a/packages/gitea-client/star/repos.star b/packages/gitea-client/star/repos.star
deleted file mode 100644
index 0ed4ede..0000000
--- a/packages/gitea-client/star/repos.star
+++ /dev/null
@@ -1,21 +0,0 @@
-# gitea-client — Repository operations
-
-load("star/http_helpers.star", "gitea_get")
-
-def get_repos(conn):
- """List repositories accessible via the connection."""
- data = gitea_get(conn, "/repos/search?limit=50&sort=updated")
- if data == None:
- return None
- repos = data if type(data) == "list" else data.get("data", [])
- out = []
- for r in repos:
- out.append({
- "full_name": r.get("full_name", ""),
- "name": r.get("name", ""),
- "owner": r.get("owner", {}).get("login", ""),
- "description": r.get("description", ""),
- "html_url": r.get("html_url", ""),
- "open_issues": r.get("open_issues_count", 0),
- })
- return out
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
deleted file mode 100644
index 0524cb9..0000000
--- a/packages/tasks/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Tasks
-
-**Status: Proof of Concept**
-
-Task management surface rebuilt as a Starlark extension. Ships with core
-for SDK validation purposes, not as a permanent commitment.
-
-## Purpose
-
-Validates all three trigger primitives (event bus, webhook, cron) and
-proves the full extension surface stack: Starlark API routes, ext_data
-tables, SDK UI components, and notifications.
-
-## Graduation Criteria
-
-To become a permanent core package, tasks must meet:
-
-1. **Feature completeness** — assignees, comments, attachments, subtasks
-2. **Test coverage** — API contract tests in the ICD test runner
-3. **Performance** — tested at 1000+ tasks with acceptable load times
-4. **Design review** — UI/UX matches platform design language
-5. **No kernel special-casing** — runs purely through extension APIs
diff --git a/packages/tasks/css/main.css b/packages/tasks/css/main.css
deleted file mode 100644
index b3c4a87..0000000
--- a/packages/tasks/css/main.css
+++ /dev/null
@@ -1,280 +0,0 @@
-/* ── Tasks Surface Styles ────────────────── */
-
-.ext-tasks-app {
- max-width: 1100px;
- margin: 0 auto;
- padding: var(--sp-6) var(--sp-5);
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-/* ── Header ──────────────────────────────── */
-.ext-tasks-header {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- margin-bottom: var(--sp-5);
- flex-shrink: 0;
-}
-.ext-tasks-title {
- font-size: 20px;
- font-weight: 600;
- color: var(--text);
-}
-.ext-tasks-stats {
- font-size: 13px;
- color: var(--text-3);
-}
-.ext-tasks-header__right {
- margin-left: auto;
- display: flex;
- align-items: center;
- gap: var(--sp-3);
-}
-.ext-tasks-view-tabs {
- display: flex;
- gap: 2px;
- background: var(--bg-raised);
- border-radius: var(--radius);
- padding: 2px;
-}
-.ext-tasks-view-tab {
- padding: 5px var(--sp-4);
- font-size: 13px;
- border: none;
- background: transparent;
- color: var(--text-2);
- border-radius: calc(var(--radius) - 2px);
- cursor: pointer;
- transition: var(--transition);
-}
-.ext-tasks-view-tab:hover { color: var(--text); background: var(--bg-hover); }
-.ext-tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
-
-/* ── List View ───────────────────────────── */
-.ext-tasks-list {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: var(--sp-2);
-}
-.ext-tasks-list__filters {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- margin-bottom: var(--sp-2);
- flex-shrink: 0;
-}
-.ext-tasks-filter {
- padding: 5px var(--sp-3);
- font-size: 13px;
- background: var(--bg-raised);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: var(--radius);
-}
-.ext-tasks-list__count {
- font-size: 12px;
- color: var(--text-3);
-}
-.ext-tasks-list__loading, .ext-tasks-list__empty {
- display: flex;
- justify-content: center;
- padding: var(--sp-10) 0;
- color: var(--text-3);
- font-size: 14px;
-}
-
-/* ── Task Card ───────────────────────────── */
-.ext-tasks-card {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: var(--sp-3) var(--sp-4);
- transition: var(--transition);
-}
-.ext-tasks-card:hover {
- border-color: var(--border-light);
- background: var(--bg-raised);
-}
-.ext-tasks-card__header {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
-}
-.ext-tasks-card__priority { font-size: 10px; }
-.ext-tasks-card__title {
- flex: 1;
- font-size: 14px;
- font-weight: 500;
- color: var(--text);
- cursor: pointer;
-}
-.ext-tasks-card__title:hover { color: var(--accent); }
-.ext-tasks-card__status {
- font-size: 11px;
- padding: 2px var(--sp-2);
- border-radius: var(--radius-lg);
- font-weight: 500;
-}
-.ext-tasks-card__status--todo { background: var(--accent-dim); color: var(--accent); }
-.ext-tasks-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
-.ext-tasks-card__status--done { background: var(--success-dim); color: var(--success); }
-.ext-tasks-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
-
-.ext-tasks-card__desc {
- font-size: 13px;
- color: var(--text-2);
- margin-top: var(--sp-2);
- line-height: 1.4;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.ext-tasks-card__footer {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- margin-top: var(--sp-2);
- font-size: 12px;
- color: var(--text-3);
-}
-.ext-tasks-card__actions {
- margin-left: auto;
- display: flex;
- gap: var(--sp-1);
- opacity: 0;
- transition: var(--transition);
-}
-.ext-tasks-card:hover .ext-tasks-card__actions { opacity: 1; }
-
-/* ── Inline buttons ──────────────────────── */
-.ext-tasks-btn {
- border: none;
- background: var(--bg-raised);
- color: var(--text-2);
- cursor: pointer;
- border-radius: var(--radius);
- transition: var(--transition);
-}
-.ext-tasks-btn--sm { padding: 2px var(--sp-2); font-size: 13px; }
-.ext-tasks-btn:hover { background: var(--bg-hover); color: var(--text); }
-.ext-tasks-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
-
-/* ── Board View ──────────────────────────── */
-.ext-tasks-board {
- flex: 1;
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: var(--sp-3);
- overflow-x: auto;
- overflow-y: hidden;
-}
-.ext-tasks-board__col {
- background: var(--bg-raised);
- border-radius: var(--radius);
- display: flex;
- flex-direction: column;
- min-height: 0;
-}
-.ext-tasks-board__col-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--sp-3) var(--sp-3);
- font-size: 13px;
- font-weight: 600;
- color: var(--text-2);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
-}
-.ext-tasks-board__col-count {
- font-size: 11px;
- color: var(--text-3);
- background: var(--bg-hover);
- padding: 1px 7px;
- border-radius: var(--radius-lg);
-}
-.ext-tasks-board__col-body {
- flex: 1;
- overflow-y: auto;
- padding: var(--sp-2);
- display: flex;
- flex-direction: column;
- gap: var(--sp-2);
-}
-.ext-tasks-board__card {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: var(--sp-3) var(--sp-3);
- cursor: pointer;
- transition: var(--transition);
-}
-.ext-tasks-board__card:hover {
- border-color: var(--accent);
-}
-.ext-tasks-board__card-title {
- font-size: 13px;
- font-weight: 500;
- color: var(--text);
- display: flex;
- align-items: center;
- gap: var(--sp-2);
-}
-.ext-tasks-board__card-due {
- font-size: 11px;
- color: var(--text-3);
- margin-top: var(--sp-1);
-}
-
-/* ── Form ────────────────────────────────── */
-.ext-tasks-form { display: flex; flex-direction: column; gap: var(--sp-3); }
-.ext-tasks-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
-.ext-tasks-form__actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-1); }
-.ext-tasks-form textarea,
-.ext-tasks-form input,
-.ext-tasks-form select {
- width: 100%;
- padding: 7px var(--sp-3);
- font-size: 13px;
- background: var(--input-bg);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- font-family: var(--font);
-}
-.ext-tasks-form textarea:focus,
-.ext-tasks-form input:focus,
-.ext-tasks-form select:focus {
- outline: none;
- border-color: var(--accent);
-}
-
-/* ── Statusbar widget ────────────────────── */
-.ext-tasks-status-widget {
- font-size: 12px;
- color: var(--text-3);
- padding: var(--sp-1) var(--sp-3);
-}
-
-/* ── Slot containers ─────────────────────── */
-.sw-slot {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
-}
-.sw-slot--statusbar {
- padding: var(--sp-1) var(--sp-3);
- border-top: 1px solid var(--border);
- font-size: 12px;
- color: var(--text-3);
- flex-shrink: 0;
-}
-.sw-slot--toolbar {
- padding: var(--sp-1) var(--sp-3);
- flex-shrink: 0;
-}
diff --git a/packages/tasks/js/main.js b/packages/tasks/js/main.js
deleted file mode 100644
index 194f25c..0000000
--- a/packages/tasks/js/main.js
+++ /dev/null
@@ -1,397 +0,0 @@
-/**
- * Tasks — Surface Entry Point (v0.1.0)
- *
- * Task management surface using the v0.2.3 SDK:
- * sw.api.ext('tasks') — scoped API client
- * sw.ui.* — primitive components
- * sw.slots — statusbar widget
- * sw.actions — create-task action
- */
-(async function () {
- 'use strict';
-
- var mount = document.getElementById('extension-mount');
- if (!mount) return;
-
- var base = window.__BASE__ || '';
- var ver = window.__VERSION__ || '0';
-
- // ── Boot SDK ───────────────────────────────
- try {
- if (!window.preact) {
- var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
- var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
- var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
- window.preact = { h, render };
- window.hooks = hooksModule;
- window.html = htmModule.default.bind(h);
- }
- var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
- await sdk.boot();
- } catch (e) {
- mount.innerHTML = 'SDK boot failed: ' + e.message + '
';
- return;
- }
-
- var { html } = window;
- var { useState, useEffect, useCallback, useRef } = hooks;
- var { render } = preact;
-
- // ── SDK modules ────────────────────────────
- var api = sw.api.ext('tasks');
- var { Button, FormField, Dialog, Spinner, Dropdown, Tabs, Banner } = sw.ui;
-
- // ── Constants ──────────────────────────────
- var STATUSES = ['todo', 'in_progress', 'done', 'cancelled'];
- var PRIORITIES = ['low', 'medium', 'high', 'urgent'];
-
- var STATUS_LABELS = {
- todo: 'To Do', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled'
- };
- var PRIORITY_COLORS = {
- low: 'var(--text-3)', medium: 'var(--accent)', high: 'var(--warning)', urgent: 'var(--danger)'
- };
-
- // ═══════════════════════════════════════════
- // TaskForm — create / edit dialog
- // ═══════════════════════════════════════════
-
- function TaskForm({ task, onSave, onClose }) {
- var [title, setTitle] = useState(task ? task.title : '');
- var [desc, setDesc] = useState(task ? task.description : '');
- var [status, setStatus] = useState(task ? task.status : STATUSES[0]);
- var [priority, setPriority] = useState(task ? task.priority : 'medium');
- var [dueDate, setDueDate] = useState(task ? task.due_date : '');
- var [tags, setTags] = useState(task ? task.tags : '');
- var [saving, setSaving] = useState(false);
- var [error, setError] = useState('');
-
- async function handleSubmit(e) {
- e.preventDefault();
- if (!title.trim()) { setError('Title is required'); return; }
- setSaving(true);
- setError('');
- try {
- var data = { title: title.trim(), description: desc, status, priority, due_date: dueDate, tags };
- if (task) {
- await api.put('/items/' + task.id, data);
- } else {
- await api.post('/items', data);
- }
- onSave();
- } catch (err) {
- setError(err.message || 'Save failed');
- } finally {
- setSaving(false);
- }
- }
-
- return html`
- <${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}>
-
- />
- `;
- }
-
-
- // ═══════════════════════════════════════════
- // TaskList — filterable task table
- // ═══════════════════════════════════════════
-
- function TaskList({ items, loading, onEdit, onRefresh }) {
- var [filter, setFilter] = useState('all');
-
- var filtered = items;
- if (filter !== 'all') {
- filtered = items.filter(function(t) { return t.status === filter; });
- }
-
- async function handleDelete(id) {
- if (!await sw.confirm('Delete this task?', { destructive: true })) return;
- await api.del('/items/' + id);
- onRefresh();
- }
-
- async function handleQuickStatus(id, newStatus) {
- await api.put('/items/' + id, { status: newStatus });
- onRefresh();
- }
-
- return html`
-
-
- setFilter(e.target.value)} class="ext-tasks-filter">
- All
- ${STATUSES.map(s => html`${STATUS_LABELS[s] || s} `)}
-
- ${filtered.length} task${filtered.length !== 1 ? 's' : ''}
-
-
- ${loading && html`
<${Spinner} size="md" />
`}
-
- ${!loading && filtered.length === 0 && html`
-
No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.
- `}
-
- ${!loading && filtered.map(function(t) {
- return html`
-
-
- ${t.description && html`
-
${t.description}
- `}
-
-
- `;
- })}
-
- `;
- }
-
-
- // ═══════════════════════════════════════════
- // TaskBoard — kanban columns
- // ═══════════════════════════════════════════
-
- function TaskBoard({ items, loading, onEdit, onRefresh }) {
- async function handleQuickStatus(id, newStatus) {
- await api.put('/items/' + id, { status: newStatus });
- onRefresh();
- }
-
- return html`
-
- ${loading && html`
<${Spinner} size="md" />
`}
- ${!loading && STATUSES.map(function(status) {
- var col = items.filter(function(t) { return t.status === status; });
- return html`
-
-
-
- ${col.map(function(t) {
- return html`
-
onEdit(t)}>
-
- ●
- ${t.title}
-
- ${t.due_date && html`
Due: ${t.due_date}
`}
-
- `;
- })}
-
-
- `;
- })}
-
- `;
- }
-
-
- // ═══════════════════════════════════════════
- // TaskApp — root component
- // ═══════════════════════════════════════════
-
- function TaskApp() {
- var [items, setItems] = useState([]);
- var [loading, setLoading] = useState(true);
- var [error, setError] = useState('');
- var [view, setView] = useState('list');
- var [editTask, setEditTask] = useState(null); // null = closed, {} = new, {id} = edit
- var [stats, setStats] = useState(null);
-
- var loadItems = useCallback(async function () {
- setLoading(true);
- try {
- var res = await api.get('/items');
- setItems(res.data || res || []);
- setError('');
- } catch (err) {
- setError(err.message || 'Failed to load tasks');
- } finally {
- setLoading(false);
- }
- }, []);
-
- var loadStats = useCallback(async function () {
- try {
- var res = await api.get('/stats');
- setStats(res);
- } catch (_) {}
- }, []);
-
- useEffect(function () {
- loadItems();
- loadStats();
- }, []);
-
- // Live updates via event bus
- useEffect(function () {
- var off = sw.on('task.*', function () {
- loadItems();
- loadStats();
- });
- return off;
- }, []);
-
- function handleSave() {
- setEditTask(null);
- loadItems();
- loadStats();
- }
-
- var tabs = [
- { id: 'list', label: 'List' },
- { id: 'board', label: 'Board' },
- ];
-
- var Topbar = sw.shell?.Topbar;
-
- return html`
-
- ${Topbar ? html`
- <${Topbar} title="Tasks">
- ${stats && html`
${stats.total || 0} total `}
-
- ${tabs.map(function(tab) {
- return html` setView(tab.id)}>${tab.label} `;
- })}
-
- <${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task/>
- />
- ` : html`
-
- `}
-
- ${error && html`<${Banner} variant="danger" text=${error} />`}
-
- ${view === 'list' && html`
- <${TaskList} items=${items} loading=${loading}
- onEdit=${setEditTask} onRefresh=${loadItems} />
- `}
-
- ${view === 'board' && html`
- <${TaskBoard} items=${items} loading=${loading}
- onEdit=${setEditTask} onRefresh=${loadItems} />
- `}
-
- ${editTask !== null && html`
- <${TaskForm} task=${editTask.id ? editTask : null}
- onSave=${handleSave}
- onClose=${() => setEditTask(null)} />
- `}
-
- `;
- }
-
-
- // ═══════════════════════════════════════════
- // Actions & Slots registration
- // ═══════════════════════════════════════════
-
- // Register create-task action
- sw.actions.register('tasks.create', {
- label: 'Create Task',
- handler: function () {
- // Emit event that the app listens to
- sw.emit('tasks.open-create', {}, { localOnly: true });
- },
- });
-
- // Statusbar widget
- function TaskStatusWidget() {
- var [count, setCount] = useState(null);
-
- useEffect(function () {
- async function load() {
- try {
- var res = await api.get('/stats');
- var open = (res.counts || {}).todo || 0;
- var inProg = (res.counts || {}).in_progress || 0;
- setCount(open + inProg);
- } catch (_) {}
- }
- load();
- var off = sw.on('task.*', load);
- return off;
- }, []);
-
- if (count === null) return null;
- return html`${count} open task${count !== 1 ? 's' : ''} `;
- }
-
- sw.slots.register('statusbar', {
- id: 'tasks-open-count',
- component: TaskStatusWidget,
- priority: 50,
- });
-
-
- // ── Mount ──────────────────────────────────
- render(html`<${TaskApp} />`, mount);
-
-})();
diff --git a/packages/tasks/manifest.json b/packages/tasks/manifest.json
deleted file mode 100644
index af1396f..0000000
--- a/packages/tasks/manifest.json
+++ /dev/null
@@ -1,74 +0,0 @@
-{
- "id": "tasks",
- "title": "Tasks",
- "type": "full",
- "tier": "starlark",
- "route": "/s/tasks",
- "auth": "authenticated",
- "layout": "single",
- "version": "0.1.0",
- "icon": "✅",
- "description": "Task management surface with lifecycle events, webhook integration, and scheduled reminders.",
- "author": "armature",
-
- "permissions": ["db.write", "notifications.send"],
-
- "api_routes": [
- {"method": "GET", "path": "/items"},
- {"method": "POST", "path": "/items"},
- {"method": "GET", "path": "/items/*"},
- {"method": "PUT", "path": "/items/*"},
- {"method": "DELETE", "path": "/items/*"},
- {"method": "GET", "path": "/stats"}
- ],
-
- "db_tables": {
- "items": {
- "columns": {
- "title": "text",
- "description": "text",
- "status": "text",
- "priority": "text",
- "assignee_id": "text",
- "creator_id": "text",
- "due_date": "text",
- "tags": "text",
- "updated_at": "text",
- "completed_at": "text"
- },
- "indexes": [
- ["status"],
- ["assignee_id"],
- ["creator_id"]
- ]
- }
- },
-
- "triggers": [
- {
- "type": "event",
- "pattern": "task.*",
- "entry_point": "on_task_event"
- },
- {
- "type": "webhook",
- "slug": "create",
- "entry_point": "on_webhook"
- }
- ],
-
- "settings": {
- "statuses": {
- "type": "string",
- "label": "Statuses",
- "description": "Comma-separated task statuses",
- "default": "todo,in_progress,done,cancelled"
- },
- "priorities": {
- "type": "string",
- "label": "Priorities",
- "description": "Comma-separated task priorities",
- "default": "low,medium,high,urgent"
- }
- }
-}
diff --git a/packages/tasks/script.star b/packages/tasks/script.star
deleted file mode 100644
index 3ea6fe7..0000000
--- a/packages/tasks/script.star
+++ /dev/null
@@ -1,263 +0,0 @@
-# Tasks — Starlark Backend (v0.1.0)
-#
-# Task management extension using ext_data + notifications + triggers.
-#
-# Entry points:
-# on_request(req) → surface API routes
-# on_task_event(event) → event trigger (task.*)
-# on_webhook(req) → webhook trigger for external task creation
-#
-# Modules: db, json, notifications, settings
-
-
-# ═══════════════════════════════════════════════
-# Helpers
-# ═══════════════════════════════════════════════
-
-def _resp(status, data):
- return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
-
-def _str(v):
- if v == None:
- return ""
- return str(v)
-
-def _now():
- """ISO-ish timestamp from Starlark (no time module — use db auto-created_at or pass from caller)."""
- return ""
-
-def _statuses():
- raw = settings.get("statuses")
- if not raw:
- return ["todo", "in_progress", "done", "cancelled"]
- return [s.strip() for s in raw.split(",") if s.strip()]
-
-def _priorities():
- raw = settings.get("priorities")
- if not raw:
- return ["low", "medium", "high", "urgent"]
- return [s.strip() for s in raw.split(",") if s.strip()]
-
-
-# ═══════════════════════════════════════════════
-# Surface API routes (/s/tasks/api/*)
-# ═══════════════════════════════════════════════
-
-def on_request(req):
- path = req["path"]
- method = req["method"]
-
- # GET /items — list tasks
- if method == "GET" and path == "/items":
- return _list_items(req)
-
- # POST /items — create task
- if method == "POST" and path == "/items":
- return _create_item(req)
-
- # GET /stats — aggregate counts
- if method == "GET" and path == "/stats":
- return _get_stats()
-
- # GET /items/:id
- if method == "GET" and path.startswith("/items/"):
- return _get_item(path[len("/items/"):])
-
- # PUT /items/:id
- if method == "PUT" and path.startswith("/items/"):
- return _update_item(path[len("/items/"):], req)
-
- # DELETE /items/:id
- if method == "DELETE" and path.startswith("/items/"):
- return _delete_item(path[len("/items/"):])
-
- return _resp(404, {"error": "not found"})
-
-
-def _list_items(req):
- q = req.get("query", {})
- filters = {}
-
- status = _str(q.get("status", ""))
- if status:
- filters["status"] = status
-
- assignee = _str(q.get("assignee_id", ""))
- if assignee:
- filters["assignee_id"] = assignee
-
- priority = _str(q.get("priority", ""))
- if priority:
- filters["priority"] = priority
-
- creator = _str(q.get("creator_id", ""))
- if creator:
- filters["creator_id"] = creator
-
- order = _str(q.get("order", "")) or "created_at"
- limit_str = _str(q.get("limit", ""))
- limit = int(limit_str) if limit_str else 100
-
- rows = db.query("items", filters=filters, order=order, limit=limit)
- return _resp(200, {"data": rows or []})
-
-
-def _create_item(req):
- body = json.decode(req.get("body", "{}"))
- user_id = req.get("user_id", "")
-
- title = _str(body.get("title", ""))
- if not title:
- return _resp(400, {"error": "title is required"})
-
- statuses = _statuses()
- status = _str(body.get("status", ""))
- if not status:
- status = statuses[0] if statuses else "todo"
-
- priorities = _priorities()
- priority = _str(body.get("priority", ""))
- if not priority:
- priority = priorities[0] if priorities else "medium"
-
- row = db.insert("items", {
- "title": title,
- "description": _str(body.get("description", "")),
- "status": status,
- "priority": priority,
- "assignee_id": _str(body.get("assignee_id", "")) or user_id,
- "creator_id": user_id,
- "due_date": _str(body.get("due_date", "")),
- "tags": _str(body.get("tags", "")),
- "updated_at": "",
- "completed_at":"",
- })
- return _resp(201, row)
-
-
-def _get_item(item_id):
- rows = db.query("items", filters={"id": item_id}, limit=1)
- if not rows:
- return _resp(404, {"error": "task not found"})
- return _resp(200, rows[0])
-
-
-def _update_item(item_id, req):
- body = json.decode(req.get("body", "{}"))
- user_id = req.get("user_id", "")
-
- # Fetch existing to detect transitions
- existing = db.query("items", filters={"id": item_id}, limit=1)
- if not existing:
- return _resp(404, {"error": "task not found"})
- old = existing[0]
-
- updates = {}
- for key in ["title", "description", "status", "priority", "assignee_id", "due_date", "tags"]:
- if key in body:
- updates[key] = _str(body[key])
-
- # Detect completion transition
- new_status = updates.get("status", "")
- old_status = _str(old.get("status", ""))
- if new_status == "done" and old_status != "done":
- updates["completed_at"] = "now" # sentinel — db module handles timestamp
-
- ok = db.update("items", item_id, updates)
- if not ok:
- return _resp(500, {"error": "update failed"})
-
- # Notify creator on completion if assignee is different
- if new_status == "done" and old_status != "done":
- creator = _str(old.get("creator_id", ""))
- assignee = _str(old.get("assignee_id", ""))
- if creator and assignee and creator != assignee:
- notifications.send(
- creator,
- "Task completed: " + _str(old.get("title", "")),
- body=_str(old.get("title", "")) + " was marked done by assignee.",
- )
-
- # Re-fetch updated row
- rows = db.query("items", filters={"id": item_id}, limit=1)
- return _resp(200, rows[0] if rows else {})
-
-
-def _delete_item(item_id):
- ok = db.delete("items", item_id)
- if not ok:
- return _resp(404, {"error": "task not found"})
- return _resp(200, {"deleted": True})
-
-
-def _get_stats():
- all_items = db.query("items", limit=10000)
- items = all_items or []
- counts = {}
- for s in _statuses():
- counts[s] = 0
- for item in items:
- st = _str(item.get("status", ""))
- if st in counts:
- counts[st] = counts[st] + 1
- else:
- counts[st] = 1
- return _resp(200, {"counts": counts, "total": len(items)})
-
-
-# ═══════════════════════════════════════════════
-# Event trigger handler
-# ═══════════════════════════════════════════════
-
-def on_task_event(event):
- """Handle task lifecycle events emitted by the platform."""
- label = event.get("event_label", "")
- payload = event.get("event_payload", {})
-
- if type(payload) == "string":
- payload = json.decode(payload) if payload else {}
-
- # Could be used for audit logging, metrics, etc.
- # For now just a placeholder — actual notifications happen inline in _update_item.
- pass
-
-
-# ═══════════════════════════════════════════════
-# Webhook trigger handler
-# ═══════════════════════════════════════════════
-
-def on_webhook(req):
- """
- External task creation via webhook.
- POST /api/v1/hooks/tasks/create with JSON body:
- { "title": "...", "description": "...", "priority": "...", "assignee_id": "..." }
- """
- raw_body = req.get("body", "{}")
- body = json.decode(raw_body) if raw_body else {}
-
- title = _str(body.get("title", ""))
- if not title:
- return {"status": 400, "body": json.encode({"error": "title is required"})}
-
- priorities = _priorities()
- priority = _str(body.get("priority", ""))
- if not priority:
- priority = priorities[0] if priorities else "medium"
-
- statuses = _statuses()
- status = statuses[0] if statuses else "todo"
-
- row = db.insert("items", {
- "title": title,
- "description": _str(body.get("description", "")),
- "status": status,
- "priority": priority,
- "assignee_id": _str(body.get("assignee_id", "")),
- "creator_id": "",
- "due_date": _str(body.get("due_date", "")),
- "tags": _str(body.get("tags", "")),
- "updated_at": "",
- "completed_at":"",
- })
-
- return {"status": 201, "body": json.encode(row), "headers": {"Content-Type": "application/json"}}
diff --git a/server/handlers/packages.go b/server/handlers/packages.go
index 938f391..0d516ce 100644
--- a/server/handlers/packages.go
+++ b/server/handlers/packages.go
@@ -213,66 +213,150 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
// POST /api/v1/admin/packages/install
//
// Also supports pre-downloaded files via gin context:
-// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
-// c.Set("_registry_source", "registry") — override source
+//
+// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
+// c.Set("_registry_source", "registry") — override source
func (h *PackageHandler) InstallPackage(c *gin.Context) {
- var tmpPath string
- var cleanupTmp bool
-
- if regFile, ok := c.Get("_registry_file"); ok {
- tmpPath = regFile.(string)
- // Don't remove — caller manages lifecycle
- } else {
- file, header, err := c.Request.FormFile("file")
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
- return
- }
- defer file.Close()
-
- // Validate extension
- validExt := strings.HasSuffix(header.Filename, ".pkg") ||
- strings.HasSuffix(header.Filename, ".surface") ||
- strings.HasSuffix(header.Filename, ".zip")
- if !validExt {
- c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
- return
- }
-
- // Size limit: 50MB
- if header.Size > 50*1024*1024 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
- return
- }
-
- // Read into temp file for zip processing
- tmpFile, err := os.CreateTemp("", "package-*.zip")
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
- return
- }
- tmpPath = tmpFile.Name()
- cleanupTmp = true
-
- if _, err := io.Copy(tmpFile, file); err != nil {
- tmpFile.Close()
- os.Remove(tmpPath)
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
- return
- }
- tmpFile.Close()
+ // Phase 1: Receive upload → temp file
+ tmpPath, cleanupTmp, err := h.receiveUpload(c)
+ if err != nil {
+ return // error already sent
}
if cleanupTmp {
defer os.Remove(tmpPath)
}
- // Open as zip
+ // Phase 2: Parse and validate archive
+ zr, manifest, mInfo, err := h.parseAndValidateArchive(c, tmpPath)
+ if err != nil {
+ return // error already sent
+ }
+ defer zr.Close()
+ pkgID := mInfo.ID
+
+ // Phase 3: Check for conflicts with core packages
+ existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
+ if existing != nil && existing.Source == "core" {
+ c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
+ return
+ }
+
+ // Phase 4: Extract static assets to disk
+ h.extractPackageAssets(pkgID, zr)
+
+ // Phase 5: Register in database
+ userID := c.GetString("user_id")
+ pkgSource := "extension"
+ if src, ok := c.Get("_registry_source"); ok {
+ pkgSource = src.(string)
+ }
+ preservedEnabled, err := h.registerPackage(c, pkgID, mInfo, pkgSource, userID, manifest)
+ if err != nil {
+ return // error already sent
+ }
+
+ // Phase 6: Apply DDL, permissions, triggers, schema migrations
+ if err := h.applySchemaAndPermissions(c, pkgID, mInfo, manifest, existing); err != nil {
+ return // error already sent
+ }
+
+ // Phase 7: Install workflow definition (if workflow type)
+ if mInfo.Type == "workflow" {
+ if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
+ log.Printf("[packages] workflow install failed for %s: %v", pkgID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()})
+ return
+ }
+ }
+
+ // Phase 8: Resolve dependencies
+ if err := h.resolveDependencies(c, pkgID, manifest); err != nil {
+ return // error already sent
+ }
+
+ // Phase 9: Auto-set default surface
+ if (mInfo.Type == "surface" || mInfo.Type == "full") && pkgSource != "core" {
+ if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
+ dflt := models.JSONMap{"id": pkgID}
+ if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
+ log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
+ } else {
+ log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
+ }
+ }
+ }
+
+ // Phase 10: Check capabilities → mark dormant if unmet
+ isDormant := h.checkCapabilities(c, pkgID, manifest, &preservedEnabled)
+
+ resp := gin.H{
+ "id": pkgID,
+ "title": mInfo.Title,
+ "type": mInfo.Type,
+ "version": mInfo.Version,
+ "source": pkgSource,
+ "enabled": preservedEnabled,
+ }
+ if isDormant {
+ resp["status"] = "dormant"
+ }
+ c.JSON(http.StatusOK, resp)
+ h.broadcastPackageChanged("installed", pkgID)
+}
+
+// ── InstallPackage phases ────────────────────────
+
+// receiveUpload reads the uploaded file into a temp file.
+// Returns the path, whether to clean up, and any error (already sent to client).
+func (h *PackageHandler) receiveUpload(c *gin.Context) (string, bool, error) {
+ if regFile, ok := c.Get("_registry_file"); ok {
+ return regFile.(string), false, nil
+ }
+
+ file, header, err := c.Request.FormFile("file")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
+ return "", false, err
+ }
+ defer file.Close()
+
+ validExt := strings.HasSuffix(header.Filename, ".pkg") ||
+ strings.HasSuffix(header.Filename, ".surface") ||
+ strings.HasSuffix(header.Filename, ".zip")
+ if !validExt {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
+ return "", false, fmt.Errorf("invalid extension")
+ }
+ if header.Size > 50*1024*1024 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
+ return "", false, fmt.Errorf("too large")
+ }
+
+ tmpFile, err := os.CreateTemp("", "package-*.zip")
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
+ return "", false, err
+ }
+ tmpPath := tmpFile.Name()
+
+ if _, err := io.Copy(tmpFile, file); err != nil {
+ tmpFile.Close()
+ os.Remove(tmpPath)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
+ return "", false, err
+ }
+ tmpFile.Close()
+ return tmpPath, true, nil
+}
+
+// parseAndValidateArchive opens the zip, parses manifest.json, validates
+// starlark entry points, runs unicode security scans, and validates manifest structure.
+func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string) (*zip.ReadCloser, map[string]any, *ManifestInfo, error) {
zr, err := zip.OpenReader(tmpPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
- return
+ return nil, nil, nil, err
}
- defer zr.Close()
// Find and parse manifest.json
var manifest map[string]any
@@ -289,19 +373,20 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
continue
}
if err := json.Unmarshal(data, &manifest); err != nil {
+ zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
- return
+ return nil, nil, nil, err
}
break
}
}
-
if manifest == nil {
+ zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
- return
+ return nil, nil, nil, fmt.Errorf("missing manifest")
}
- // Scripts are loaded from disk at runtime — no _starlark_script injection.
+ // Validate starlark entry point
if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" {
entryPoint := "script.star"
if ep, ok := manifest["entry_point"].(string); ok && ep != "" {
@@ -316,13 +401,13 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
if !found {
+ zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)})
- return
+ return nil, nil, nil, fmt.Errorf("missing entry point")
}
}
- // Unicode security gate — scan all scannable files for invisible/deceptive
- // characters before writing anything to the extension store.
+ // Unicode security scan
scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
@@ -344,116 +429,94 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
findings := sandbox.ScanSource(string(data), f.Name)
if len(findings) > 0 {
if blocked, reason := sandbox.Verdict(findings); blocked {
+ zr.Close()
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "extension_blocked",
"reason": fmt.Sprintf("%s (file: %s)", reason, f.Name),
})
- return
+ return nil, nil, nil, fmt.Errorf("blocked")
}
}
}
- // Validate manifest structure (required fields, type, constraints)
+ // Validate manifest structure
mInfo, err := ValidateManifest(manifest)
if err != nil {
+ zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
+ return nil, nil, nil, err
}
- pkgID := mInfo.ID
- title := mInfo.Title
- pkgType := mInfo.Type
- // Package signing hook (schema reserved — no crypto verification yet)
+ // Signing hook (reserved)
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
if mInfo.Signature != "" {
- log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", pkgID, mInfo.Signature)
+ log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", mInfo.ID, mInfo.Signature)
} else {
- log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", pkgID)
+ log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", mInfo.ID)
}
}
- // Check for conflicts with core packages
- existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
- if existing != nil && existing.Source == "core" {
- c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
+ return zr, manifest, mInfo, nil
+}
+
+// extractPackageAssets extracts js/, css/, assets/, star/ files to the packages directory.
+func (h *PackageHandler) extractPackageAssets(pkgID string, zr *zip.ReadCloser) {
+ if h.packagesDir == "" {
return
}
+ destDir := filepath.Join(h.packagesDir, pkgID)
+ os.MkdirAll(destDir, 0755)
- // Extract static assets (js/, css/, assets/) to packagesDir/{id}/
- if h.packagesDir != "" {
- destDir := filepath.Join(h.packagesDir, pkgID)
- os.MkdirAll(destDir, 0755)
-
- for _, f := range zr.File {
- if f.FileInfo().IsDir() {
- continue
- }
-
- relPath := extractableRelPath(f.Name)
- if relPath == "" {
- continue
- }
-
- destPath := filepath.Join(destDir, relPath)
-
- // Security: prevent path traversal
- if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
- continue
- }
-
- os.MkdirAll(filepath.Dir(destPath), 0755)
-
- rc, err := f.Open()
- if err != nil {
- continue
- }
- out, err := os.Create(destPath)
- if err != nil {
- rc.Close()
- continue
- }
- io.Copy(out, rc)
- out.Close()
+ for _, f := range zr.File {
+ if f.FileInfo().IsDir() {
+ continue
+ }
+ relPath := extractableRelPath(f.Name)
+ if relPath == "" {
+ continue
+ }
+ destPath := filepath.Join(destDir, relPath)
+ if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
+ continue // path traversal
+ }
+ os.MkdirAll(filepath.Dir(destPath), 0755)
+ rc, err := f.Open()
+ if err != nil {
+ continue
+ }
+ out, err := os.Create(destPath)
+ if err != nil {
rc.Close()
+ continue
}
-
- log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
+ io.Copy(out, rc)
+ out.Close()
+ rc.Close()
}
+ log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
+}
- // Use validated manifest fields for DB columns
- version := mInfo.Version
- description := mInfo.Description
- author := mInfo.Author
- tier := mInfo.Tier
-
- userID := c.GetString("user_id")
-
- pkgSource := "extension"
- if src, ok := c.Get("_registry_source"); ok {
- pkgSource = src.(string)
- }
-
- // Register in database via Seed (upsert — handles re-install)
- if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
+// registerPackage seeds the package in the database and updates metadata fields.
+// Returns the preserved enabled state and any error (already sent to client).
+func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *ManifestInfo, pkgSource, userID string, manifest map[string]any) (bool, error) {
+ if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, mInfo.Title, pkgSource, manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
- return
+ return false, err
}
- // Read back to get the preserved enabled state (Seed doesn't override it)
- existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
+ existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
preservedEnabled := true
if existing != nil {
preservedEnabled = existing.Enabled
}
- // Update fields that Seed doesn't set (type, version, author, etc.)
pkg := &store.PackageRegistration{
- Title: title,
- Type: pkgType,
- Version: version,
- Description: description,
- Author: author,
- Tier: tier,
+ Title: mInfo.Title,
+ Type: mInfo.Type,
+ Version: mInfo.Version,
+ Description: mInfo.Description,
+ Author: mInfo.Author,
+ Tier: mInfo.Tier,
IsSystem: false,
Enabled: preservedEnabled,
Manifest: manifest,
@@ -464,17 +527,19 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
}
+ return preservedEnabled, nil
+}
+// applySchemaAndPermissions creates extension tables, syncs permissions/triggers,
+// and runs schema migrations.
+func (h *PackageHandler) applySchemaAndPermissions(c *gin.Context, pkgID string, mInfo *ManifestInfo, manifest map[string]any, existing *store.PackageRegistration) error {
if tables, ok := ParseDBTables(manifest); ok {
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil {
log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err)
}
}
- // This creates the permission rows and sets status to pending_review
- // if the package declares permissions.
SyncManifestPermissions(c, h.stores, pkgID, manifest)
-
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)
newSchemaVersion := ParseSchemaVersion(manifest)
@@ -488,7 +553,7 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
oldSchemaVersion, newSchemaVersion),
})
- return
+ return fmt.Errorf("downgrade")
}
if newSchemaVersion > oldSchemaVersion {
if err := RunSchemaMigrations(
@@ -500,89 +565,77 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "schema migration failed: " + err.Error(),
})
- return
+ return err
}
}
}
+ return nil
+}
- if pkgType == "workflow" {
- if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
- log.Printf("[packages] workflow install failed for %s: %v", pkgID, err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()})
- return
- }
+// resolveDependencies validates and records library dependencies.
+func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manifest map[string]any) error {
+ deps, ok := manifest["dependencies"].(map[string]any)
+ if !ok || len(deps) == 0 {
+ return nil
}
- // Libraries must be installed first; consumers declare them.
- // If a dependency is missing but available as a bundled package, auto-install it.
- if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
- // Clear stale dependency records from a previous install of the same consumer.
- if h.stores.Dependencies != nil {
- h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
- }
- for libID, vSpec := range deps {
- lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
- if err != nil || lib == nil {
- // Attempt auto-install from bundled packages
- if h.bundledDir != "" {
- pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
- if _, statErr := os.Stat(pkgPath); statErr == nil {
- log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
- if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
- })
- return
- }
- // Re-fetch after install
- lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
+ if h.stores.Dependencies != nil {
+ h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
+ }
+
+ for libID, vSpec := range deps {
+ lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
+ if err != nil || lib == nil {
+ // Attempt auto-install from bundled packages
+ if h.bundledDir != "" {
+ pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
+ if _, statErr := os.Stat(pkgPath); statErr == nil {
+ log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
+ if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
+ })
+ return installErr
}
- }
- if lib == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
- return
+ lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
}
}
- if lib.Type != "library" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
- return
- }
- if lib.Status == "suspended" || lib.Status == "dormant" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
- return
- }
- versionSpec, _ := vSpec.(string)
- if versionSpec == "" {
- versionSpec = ">=0.0.0"
- }
- if h.stores.Dependencies != nil {
- if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
- ConsumerID: pkgID,
- LibraryID: libID,
- VersionSpec: versionSpec,
- ResolvedVer: lib.Version,
- }); err != nil {
- log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
- return
- }
+ if lib == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
+ return fmt.Errorf("missing dep: %s", libID)
}
}
- log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
- }
-
- if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" {
- if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
- dflt := models.JSONMap{"id": pkgID}
- if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
- log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
- } else {
- log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
+ if lib.Type != "library" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
+ return fmt.Errorf("not a library: %s", libID)
+ }
+ if lib.Status == "suspended" || lib.Status == "dormant" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
+ return fmt.Errorf("dep %s is %s", libID, lib.Status)
+ }
+ versionSpec, _ := vSpec.(string)
+ if versionSpec == "" {
+ versionSpec = ">=0.0.0"
+ }
+ if h.stores.Dependencies != nil {
+ if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
+ ConsumerID: pkgID,
+ LibraryID: libID,
+ VersionSpec: versionSpec,
+ ResolvedVer: lib.Version,
+ }); err != nil {
+ log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
+ return err
}
}
}
+ log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
+ return nil
+}
- // Known capabilities: (none yet — chat and legacy-sdk are future/removed).
+// checkCapabilities marks a package dormant if it declares unmet `requires` entries.
+func (h *PackageHandler) checkCapabilities(c *gin.Context, pkgID string, manifest map[string]any, preservedEnabled *bool) bool {
knownCaps := map[string]bool{}
var unmetReqs []string
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
@@ -593,27 +646,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
}
- isDormant := len(unmetReqs) > 0
- if isDormant {
- h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
- h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
- preservedEnabled = false
- log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
+ if len(unmetReqs) == 0 {
+ return false
}
-
- resp := gin.H{
- "id": pkgID,
- "title": title,
- "type": pkgType,
- "version": version,
- "source": pkgSource,
- "enabled": preservedEnabled,
- }
- if isDormant {
- resp["status"] = "dormant"
- }
- c.JSON(http.StatusOK, resp)
- h.broadcastPackageChanged("installed", pkgID)
+ h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
+ h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
+ *preservedEnabled = false
+ log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
+ return true
}
// extractableRelPath returns the relative path for a zip entry if it
diff --git a/server/pages/pages.go b/server/pages/pages.go
index e0253cd..486e436 100644
--- a/server/pages/pages.go
+++ b/server/pages/pages.go
@@ -25,6 +25,7 @@ import (
"github.com/gin-gonic/gin"
"armature/config"
+ "armature/models"
"armature/store"
)
@@ -631,18 +632,20 @@ func (e *Engine) RenderLogin() gin.HandlerFunc {
// WorkflowPageData is passed to the workflow.html template via PageData.Data.
type WorkflowPageData struct {
- ChannelID string
- ChannelTitle string
- ChannelDescription string
- SessionID string
- SessionName string
- StageMode string // custom | form_only | form_chat | review
- StageName string
- FormTemplateJSON string // typed form template JSON (empty if custom)
- TotalStages int
- CurrentStage int
- SurfacePkgID string
- BrandingJSON string
+ EntryToken string
+ WorkflowTitle string
+ WorkflowDescription string
+ SessionID string
+ SessionName string
+ StageMode string // form | review | delegated | automated
+ StageName string
+ FormTemplateJSON string // typed form template JSON (empty if delegated)
+ TotalStages int
+ CurrentStage int
+ SurfacePkgID string
+ BrandingJSON string
+ InstanceID string // workflow instance ID (used for API calls)
+ Status string // pending | active | completed | cancelled | stale
}
// WorkflowLandingPageData is passed to workflow-landing.html.
@@ -660,49 +663,106 @@ type WorkflowLandingPageData struct {
PersonaName string
PersonaIcon string
StageCount int
- FirstStageMode string // custom | form_only | form_chat
+ FirstStageMode string // form | review | delegated | automated
ResumeURL string // non-empty if visitor has an active session
}
-// RenderWorkflow renders the workflow entry page for anonymous session visitors.
+// RenderWorkflow renders the workflow execution page for a running instance.
+// Route: /w/:id — :id is either an instance ID or a public entry token.
// The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
return func(c *gin.Context) {
- // :id is the public entry token (from /w/:id)
- channelID := c.Param("id")
+ ctx := c.Request.Context()
+ routeID := c.Param("id")
sessionID := c.GetString("session_id")
-
- var title, description string
- if title == "" {
- title = "Workflow"
- }
-
- // Load session display name
sessionName := "Visitor"
- // Load workflow stage info for form rendering
- var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
- var totalStages, currentStage int
- stageMode = "custom" // default
-
instanceName, _, _ := e.loadBranding()
+ // Try to load instance — the route param may be an instance ID
+ // or an entry token. Also check the ?token= query param.
+ var inst *models.WorkflowInstance
+ if e.stores.Workflows != nil {
+ // First try: query param token (from landing page redirect)
+ if qToken := c.Query("token"); qToken != "" {
+ inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, qToken)
+ }
+ // Second try: route param as entry token
+ if inst == nil {
+ inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, routeID)
+ }
+ // Third try: route param as instance ID
+ if inst == nil {
+ inst, _ = e.stores.Workflows.GetInstance(ctx, routeID)
+ }
+ }
+
+ if inst == nil {
+ c.String(http.StatusNotFound, "Workflow instance not found")
+ return
+ }
+
+ // Load workflow definition for title, description, branding
+ title, description := "Workflow", ""
+ var brandingJSON string
+ wf, _ := e.stores.Workflows.GetByID(ctx, inst.WorkflowID)
+ if wf != nil {
+ title = wf.Name
+ description = wf.Description
+ if len(wf.Branding) > 0 {
+ brandingJSON = string(wf.Branding)
+ }
+ }
+
+ // Load stages to resolve current stage info
+ var stageMode, stageName, formTplJSON, surfacePkgID string
+ var totalStages, currentStage int
+ stageMode = "form" // default
+
+ stages, _ := e.stores.Workflows.ListStages(ctx, inst.WorkflowID)
+ totalStages = len(stages)
+ for i, s := range stages {
+ if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage {
+ currentStage = i
+ stageName = s.Name
+ stageMode = s.StageMode
+ if stageMode == "" {
+ stageMode = "form"
+ }
+ if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "null" {
+ formTplJSON = string(s.FormTemplate)
+ }
+ if s.SurfacePkgID != nil {
+ surfacePkgID = *s.SurfacePkgID
+ }
+ break
+ }
+ }
+
+ // Resolve the actual entry token from the instance (used by JS API calls)
+ entryToken := ""
+ if inst.EntryToken != nil {
+ entryToken = *inst.EntryToken
+ }
+
e.Render(c, "workflow.html", PageData{
Surface: "workflow",
InstanceName: instanceName,
Data: WorkflowPageData{
- ChannelID: channelID,
- ChannelTitle: title,
- ChannelDescription: description,
- SessionID: sessionID,
- SessionName: sessionName,
- StageMode: stageMode,
- StageName: stageName,
- FormTemplateJSON: formTplJSON,
- TotalStages: totalStages,
- CurrentStage: currentStage,
- SurfacePkgID: surfacePkgID,
- BrandingJSON: brandingJSON,
+ EntryToken: entryToken,
+ WorkflowTitle: title,
+ WorkflowDescription: description,
+ SessionID: sessionID,
+ SessionName: sessionName,
+ StageMode: stageMode,
+ StageName: stageName,
+ FormTemplateJSON: formTplJSON,
+ TotalStages: totalStages,
+ CurrentStage: currentStage,
+ SurfacePkgID: surfacePkgID,
+ BrandingJSON: brandingJSON,
+ InstanceID: inst.ID,
+ Status: inst.Status,
},
})
}
diff --git a/server/pages/templates/workflow-landing.html b/server/pages/templates/workflow-landing.html
index a5d784f..e5752e7 100644
--- a/server/pages/templates/workflow-landing.html
+++ b/server/pages/templates/workflow-landing.html
@@ -146,19 +146,15 @@
{{.Data.Description}}
{{end}}
- {{if and .Data.PersonaName (ne .Data.FirstStageMode "form_only")}}
+ {{if and .Data.PersonaName (ne .Data.FirstStageMode "form")}}
{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}
- {{if eq .Data.FirstStageMode "form_chat"}}
-
Fill out a form and chat with {{.Data.PersonaName}}
- {{else}}
-
You'll be chatting with {{.Data.PersonaName}}
- {{end}}
+
You'll be working with {{.Data.PersonaName}}
{{end}}
- {{if eq .Data.FirstStageMode "form_only"}}Fill Out Form{{else}}Start{{end}}
+ {{if eq .Data.FirstStageMode "form"}}Fill Out Form{{else if eq .Data.FirstStageMode "review"}}Start Review{{else}}Start{{end}}
{{if .Data.ResumeURL}}
diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html
index e2f89a7..68a1767 100644
--- a/server/pages/templates/workflow.html
+++ b/server/pages/templates/workflow.html
@@ -4,7 +4,7 @@
- {{.Data.ChannelTitle}} — {{.InstanceName}}
+ {{.Data.WorkflowTitle}} — {{.InstanceName}}
@@ -93,52 +93,22 @@
.wf-form-success h3 { font-size: 18px; margin-bottom: 8px; }
.wf-form-success p { color: var(--text-2); }
- /* ── Chat ───────────────────────── */
- .wf-chat {
- flex: 1; overflow-y: auto; padding: 16px 24px;
- }
- .wf-chat .message {
- margin-bottom: 12px; padding: 10px 14px;
- border-radius: 8px; max-width: 80%;
- }
- .wf-chat .message.user { background: var(--accent-dim); margin-left: auto; }
- .wf-chat .message.assistant { background: var(--bg-raised); }
- .wf-chat .message .meta { font-size: 11px; color: var(--text-3); margin-bottom: 4px; }
- .wf-chat .message .content { font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
-
- .wf-input {
- padding: 12px 24px; border-top: 1px solid var(--border);
- background: var(--bg-surface); display: flex; gap: 8px;
- }
- .wf-input textarea {
- flex: 1; resize: none; background: var(--input-bg); color: var(--text);
- border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px;
- font-family: var(--font); font-size: 14px; min-height: 42px; max-height: 160px;
- }
- .wf-input textarea:focus { outline: none; border-color: var(--accent); }
- .wf-input button {
- background: var(--accent); color: #fff; border: none; border-radius: 8px;
- padding: 0 20px; font-weight: 600; cursor: pointer; font-size: 14px;
- }
- .wf-input button:hover { background: var(--accent-hover); }
- .wf-input button:disabled { opacity: 0.5; cursor: default; }
-
.wf-session-info {
padding: 8px 24px; font-size: 12px; color: var(--text-3);
border-top: 1px solid var(--border); background: var(--bg);
}
- /* ── Form+Chat layout ───────────── */
- .wf-body-split { display: flex; flex: 1; overflow: hidden; }
- .wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; }
- .wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; }
- .wf-body-split .wf-chat { flex: 1; }
+ /* ── Unsupported mode fallback ─── */
+ .wf-unsupported {
+ flex: 1; display: flex; align-items: center; justify-content: center;
+ padding: 24px; text-align: center; color: var(--text-2);
+ }
- /* ── Branding (v0.35.0) ────────── */
+ /* ── Branding ────────────────────── */
.wf-branding-logo { max-height: 32px; margin-bottom: 4px; }
.wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; }
- /* ── Progressive Forms (v0.35.0) ── */
+ /* ── Progressive Forms ────────── */
.wf-fieldset-nav {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; padding-bottom: 12px;
@@ -175,8 +145,8 @@
{{if .Data.StageName}}
@@ -188,62 +158,55 @@
{{end}}
-
-
+
{{if .Data.SurfacePkgID}}
- {{else if eq .Data.StageMode "form_only"}}
+ {{else if eq .Data.StageMode "form"}}
- {{else if eq .Data.StageMode "form_chat"}}
-
{{else if eq .Data.StageMode "review"}}
+ {{else if eq .Data.Status "completed"}}
+
{{else}}
-
-
Comments (${(issue.comments || []).length})
- ${(issue.comments || []).length === 0 && html` -No comments yet.
- `} - ${(issue.comments || []).map(function (c, i) { - return html` -