Feat v0.7.9 workflow independence (#63)
All checks were successful
CI/CD / detect-changes (push) Successful in 20s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / test-go-pg (push) Successful in 3m0s
CI/CD / build-and-deploy (push) Successful in 1m26s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #63.
This commit is contained in:
2026-04-02 22:31:47 +00:00
committed by xcaliber
parent 3cdfdcf943
commit a9cf71b76d
27 changed files with 1324 additions and 4205 deletions

View File

@@ -2,6 +2,53 @@
All notable changes to Armature are documented here. 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 ## v0.7.7 — API Tokens + Extension Permissions
Personal access tokens (PATs) for programmatic API access, plus extension-declared Personal access tokens (PATs) for programmatic API access, plus extension-declared

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap # Armature — Roadmap
## Current: v0.7.7API Tokens + Extension Permissions ## Current: v0.7.9Workflow Independence Audit
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
sandbox, storage, realtime, and ops are kernel primitives. Everything else 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 | | 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). | | 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). | | 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** **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* dependency. Chat, notifications, and other packages should *enhance*
workflows but never be required for core operation. workflows but never be required for core operation.
**RenderWorkflow Fix (critical)**
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| Public entry flow | | Verify workflow start → instance creation → stage progression works without chat package installed. Landing page → form → completion. | | 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. |
| 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. | | Rename WorkflowPageData fields | done | `ChannelID``EntryToken`, `ChannelTitle``WorkflowTitle`, `ChannelDescription``WorkflowDescription`. Vestigial chat-era names removed. |
| Instance lifecycle | | Create, advance, complete, cancel — full lifecycle without chat dependency. Engine doesn't error when chat unavailable. | | Align stage mode values | done | Template now uses Go constants (`form`, `review`, `delegated`, `automated`) instead of legacy names (`form_only`, `form_chat`). |
| Admin/team-admin UI | | All CRUD, monitoring, and stage builder work without optional packages. No broken references. | | 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. |
| E2E without chat | | Landing page → instance → stage progression → completion tested with chat package disabled. | | Fix landing page mode mapping | done | `workflow-landing.html` conditionals updated to match Go stage mode constants. |
| Deferred test coverage | | store/ unit tests (PG + SQLite), InstallPackage decomposition, workflow/ engine integration tests. Carried from v0.7.6. | | 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, Four additions to existing sandbox modules. No new files beyond tests,
no schema changes. Motivated by real-world extension porting feedback: 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`. | | `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. | | 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 New `batch` sandbox module. Enables extensions to parallelize arbitrary
Starlark callables — including library function calls via `lib.require()`. Starlark callables — including library function calls via `lib.require()`.

177
ci/e2e-workflow-nochat.sh Executable file
View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 =
'<a href="' + esc(base) + '/" class="ext-editor-topbar-back" title="Back to chat">' +
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
'Back' +
'</a>' +
'<div class="ext-editor-topbar-sep"></div>' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' +
'<div class="ext-editor-ws-selector" id="editorWsSelector">' +
'<button class="ext-editor-ws-selector-btn" id="editorWsSelectorBtn">' +
'<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' +
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
'</button>' +
'<div class="ext-editor-ws-dropdown" id="editorWsDropdown">' +
'<div id="editorWsList" class="ext-editor-ws-list"></div>' +
'<div class="ext-editor-ws-dropdown-divider"></div>' +
'<button class="ext-editor-ws-dropdown-item ext-editor-ws-new" id="editorWsNewBtn">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
'New Workspace' +
'</button>' +
'</div>' +
'</div>' +
'<div class="ext-editor-topbar-branch" id="editorBranchBadge" style="display:none;">' +
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' +
'<span id="editorBranchName" class="ext-editor-topbar-branch-text">main</span>' +
'</div>' +
'<div style="flex:1;"></div>' +
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>' +
'</button>';
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 =
'<div class="ext-editor-bootstrap-card">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' +
'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>' +
'</svg>' +
'<h3 style="margin:0 0 16px;font-size:16px;">Open a Workspace</h3>' +
'<div id="editorBootstrapList" style="margin-bottom:16px;">' +
'<div style="font-size:12px;color:var(--text-3);">Loading workspaces\u2026</div>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">' +
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
'<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' +
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
'</div>' +
'<input type="text" id="editorBootstrapName" class="ext-editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
'<button id="editorBootstrapBtn" class="ext-editor-bootstrap-btn">Create Workspace</button>' +
'</div>';
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 = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading\u2026</div>';
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
listEl.innerHTML = '';
if (!workspaces.length) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
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 = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
}
}
// ── 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 = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
return;
}
listEl.innerHTML = '';
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'ext-editor-bootstrap-ws-item';
item.innerHTML =
'<span class="ext-editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="ext-editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
}
}
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);
})();

View File

@@ -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 }
]
}

View File

@@ -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 */

View File

@@ -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 = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
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`
<div class="user-menu-container" ref=${menuRef}></div>
${needsConn ? html`<${ConnectionSetup} />` : html`
<div class="ext-git-board-shell">
<header class="ext-git-board-header">
<div class="ext-git-board-header__left">
<h1 class="ext-git-board-title">Git Board</h1>
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
</div>
<div class="ext-git-board-header__right">
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${loadBoard} disabled=${loading}>
${loading ? '↻' : 'Refresh'}
</button>
</div>
</header>
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
<div class="ext-git-board-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
`}
</div>
`}
${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`}
`;
}
// ── Connection Setup ─────────────────────────
function ConnectionSetup() {
return html`
<div class="ext-git-board-shell">
<header class="ext-git-board-header">
<div class="ext-git-board-header__left"><h1 class="ext-git-board-title">Git Board</h1></div>
</header>
<div class="ext-git-board-setup">
<h2>Connect to Gitea</h2>
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
<p>Ask your admin to add a <strong>Gitea</strong> connection in
<strong>Admin → Connections</strong>, or add a personal one in
<strong>Settings → Connections</strong>.</p>
<button class="sw-btn sw-btn--primary sw-btn--sm"
onClick=${function () { window.location.href = base + '/settings'; }}>
Open Settings
</button>
<p class="ext-git-board-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
</div>
</div>
`;
}
// ── Repo Picker ────────────────────────────
function RepoPicker({ repos, owner, repo, onSelect }) {
var current = owner + '/' + repo;
return html`
<select class="ext-git-board-repo-picker" value=${current}
onChange=${function (e) {
var parts = e.target.value.split('/');
onSelect(parts[0], parts.slice(1).join('/'));
}}>
${repos.map(function (r) {
return html`<option key=${r.full_name} value=${r.full_name}>${r.full_name}</option>`;
})}
</select>
`;
}
// ── 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`
<div class="ext-git-board-board">
<${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} />`; })}
<//>
</div>
`;
}
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`
<div class="ext-git-board-column ${over ? 'ext-git-board-column--dragover' : ''}" ...${handlers}>
<div class="ext-git-board-column__header">
<span class="ext-git-board-column__title">${title}</span>
<span class="ext-git-board-column__count">${count || 0}</span>
</div>
<div class="ext-git-board-column__cards">${children}</div>
</div>
`;
}
function IssueCard({ issue, onClick }) {
return html`
<div class="ext-git-board-card" draggable="true"
onDragStart=${function (e) {
e.dataTransfer.setData('text/plain', String(issue.number));
e.dataTransfer.effectAllowed = 'move';
}}
onClick=${function (e) {
e.preventDefault();
if (onClick) onClick(issue.number);
}}>
<div class="ext-git-board-card__header">
<span class="ext-git-board-card__number">#${issue.number}</span>
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
</div>
<div class="ext-git-board-card__title">${esc(issue.title)}</div>
<div class="ext-git-board-card__labels">
${(issue.labels || []).map(function (l) {
return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`;
})}
</div>
</div>
`;
}
function PRCard({ pr }) {
return html`
<a class="ext-git-board-card ext-git-board-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
<div class="ext-git-board-card__header">
<span class="ext-git-board-card__number">#${pr.number}</span>
<span class="ext-git-board-card__branch">${esc(pr.head)}${esc(pr.base)}</span>
</div>
<div class="ext-git-board-card__title">${esc(pr.title)}</div>
<div class="ext-git-board-card__meta">
<span>@${esc(pr.user)}</span>
<span>${timeAgo(pr.created_at)}</span>
</div>
</a>
`;
}
// ── 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`
<div class="ext-git-board-modal-overlay" onClick=${function (e) {
if (e.target.classList.contains('ext-git-board-modal-overlay')) onClose(changed);
}}>
<div class="ext-git-board-modal">
<div class="ext-git-board-modal__header">
<div class="ext-git-board-modal__title-row">
${loading ? 'Loading…' : html`
<span class="ext-git-board-card__number">#${number}</span>
<h2 class="ext-git-board-modal__title">${esc(issue && issue.title)}</h2>
`}
</div>
<button class="ext-git-board-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
</div>
${!loading && issue && html`
<div class="ext-git-board-modal__body" ref=${bodyRef}>
<div class="ext-git-board-modal__meta">
<span class="ext-git-board-badge ${issue.state === 'open' ? 'ext-git-board-badge--green' : 'ext-git-board-badge--muted'}">${issue.state}</span>
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
${issue.created_at && html`<span class="ext-git-board-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
class="ext-git-board-modal__extlink">Open in Gitea ↗</a>
</div>
${issue.labels && issue.labels.length > 0 && html`
<div class="ext-git-board-card__labels" style="margin-bottom:12px;">
${issue.labels.map(function (l) { return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`; })}
</div>
`}
<div class="ext-git-board-modal__description">
${issue.body ? html`<pre class="ext-git-board-modal__body-text">${esc(issue.body)}</pre>`
: html`<p class="ext-git-board-modal__empty">No description.</p>`}
</div>
<div class="ext-git-board-modal__comments">
<h3 class="ext-git-board-modal__section-title">Comments (${(issue.comments || []).length})</h3>
${(issue.comments || []).length === 0 && html`
<p class="ext-git-board-modal__empty">No comments yet.</p>
`}
${(issue.comments || []).map(function (c, i) {
return html`
<div class="ext-git-board-comment" key=${i}>
<div class="ext-git-board-comment__header">
<strong>@${esc(c.user)}</strong>
<span class="ext-git-board-comment__date">${timeAgo(c.created_at)}</span>
</div>
<div class="ext-git-board-comment__body">${esc(c.body)}</div>
</div>
`;
})}
</div>
<div class="ext-git-board-modal__add-comment">
<textarea class="ext-git-board-modal__textarea" rows="3"
placeholder="Add a comment…"
value=${comment}
onInput=${function (e) { setComment(e.target.value); }}
onKeyDown=${function (e) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
}} />
<div class="ext-git-board-modal__actions">
<button class="sw-btn sw-btn--primary sw-btn--sm" disabled=${posting || !comment.trim()}
onClick=${postComment}>
${posting ? 'Posting…' : 'Comment'}
</button>
<button class="sw-btn sw-btn--secondary sw-btn--sm ${issue.state === 'open' ? 'sw-btn sw-btn--danger sw-btn--md' : 'sw-btn sw-btn--secondary sw-btn--md'}"
onClick=${toggleState}>
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
</button>
</div>
</div>
</div>
`}
</div>
</div>
`;
}
// ── 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');
})();

View File

@@ -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": ""
}
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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),
}

View File

@@ -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"}}

View File

@@ -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", "")}

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
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'}>
<form onSubmit=${handleSubmit} class="ext-tasks-form">
${error && html`<${Banner} variant="danger" text=${error} />`}
<${FormField} label="Title" required>
<input type="text" value=${title} onInput=${e => setTitle(e.target.value)}
placeholder="What needs to be done?" autofocus />
<//>
<${FormField} label="Description">
<textarea rows="3" value=${desc} onInput=${e => setDesc(e.target.value)}
placeholder="Details (optional)" />
<//>
<div class="ext-tasks-form__row">
<${FormField} label="Status">
<select value=${status} onChange=${e => setStatus(e.target.value)}>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
</select>
<//>
<${FormField} label="Priority">
<select value=${priority} onChange=${e => setPriority(e.target.value)}>
${PRIORITIES.map(p => html`<option value=${p}>${p}</option>`)}
</select>
<//>
</div>
<div class="ext-tasks-form__row">
<${FormField} label="Due Date">
<input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} />
<//>
<${FormField} label="Tags">
<input type="text" value=${tags} onInput=${e => setTags(e.target.value)}
placeholder="comma-separated" />
<//>
</div>
<div class="ext-tasks-form__actions">
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
<${Button} variant="primary" type="submit" disabled=${saving}>
${saving ? 'Saving…' : (task ? 'Update' : 'Create')}
<//>
</div>
</form>
<//>
`;
}
// ═══════════════════════════════════════════
// 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`
<div class="ext-tasks-list">
<div class="ext-tasks-list__filters">
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="ext-tasks-filter">
<option value="all">All</option>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
</select>
<span class="ext-tasks-list__count">${filtered.length} task${filtered.length !== 1 ? 's' : ''}</span>
</div>
${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
${!loading && filtered.length === 0 && html`
<div class="ext-tasks-list__empty">No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.</div>
`}
${!loading && filtered.map(function(t) {
return html`
<div class="ext-tasks-card" key=${t.id}>
<div class="ext-tasks-card__header">
<span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
</span>
<span class="ext-tasks-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
<span class="ext-tasks-card__status ext-tasks-card__status--${t.status}">
${STATUS_LABELS[t.status] || t.status}
</span>
</div>
${t.description && html`
<div class="ext-tasks-card__desc">${t.description}</div>
`}
<div class="ext-tasks-card__footer">
${t.due_date && html`<span class="ext-tasks-card__due">Due: ${t.due_date}</span>`}
${t.tags && html`<span class="ext-tasks-card__tags">${t.tags}</span>`}
<div class="ext-tasks-card__actions">
${t.status !== 'done' && html`
<button class="ext-tasks-btn ext-tasks-btn--sm" onClick=${() => handleQuickStatus(t.id, 'done')}
title="Mark done">✓</button>
`}
<button class="ext-tasks-btn ext-tasks-btn--sm ext-tasks-btn--danger" onClick=${() => handleDelete(t.id)}
title="Delete">×</button>
</div>
</div>
</div>
`;
})}
</div>
`;
}
// ═══════════════════════════════════════════
// TaskBoard — kanban columns
// ═══════════════════════════════════════════
function TaskBoard({ items, loading, onEdit, onRefresh }) {
async function handleQuickStatus(id, newStatus) {
await api.put('/items/' + id, { status: newStatus });
onRefresh();
}
return html`
<div class="ext-tasks-board">
${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
${!loading && STATUSES.map(function(status) {
var col = items.filter(function(t) { return t.status === status; });
return html`
<div class="ext-tasks-board__col" key=${status}>
<div class="ext-tasks-board__col-header">
<span class="ext-tasks-board__col-title">${STATUS_LABELS[status] || status}</span>
<span class="ext-tasks-board__col-count">${col.length}</span>
</div>
<div class="ext-tasks-board__col-body">
${col.map(function(t) {
return html`
<div class="ext-tasks-board__card" key=${t.id} onClick=${() => onEdit(t)}>
<div class="ext-tasks-board__card-title">
<span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
${t.title}
</div>
${t.due_date && html`<div class="ext-tasks-board__card-due">Due: ${t.due_date}</div>`}
</div>
`;
})}
</div>
</div>
`;
})}
</div>
`;
}
// ═══════════════════════════════════════════
// 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`
<div class="ext-tasks-app">
${Topbar ? html`
<${Topbar} title="Tasks">
${stats && html`<span class="ext-tasks-stats">${stats.total || 0} total</span>`}
<div class="ext-tasks-view-tabs">
${tabs.map(function(tab) {
return html`<button key=${tab.id}
class="ext-tasks-view-tab ${view === tab.id ? 'ext-tasks-view-tab--active' : ''}"
onClick=${() => setView(tab.id)}>${tab.label}</button>`;
})}
</div>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
<//>
` : html`
<div class="ext-tasks-header">
<h1 class="ext-tasks-title">Tasks</h1>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
</div>
`}
${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)} />
`}
</div>
`;
}
// ═══════════════════════════════════════════
// 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`<span class="ext-tasks-status-widget" title="Open tasks">${count} open task${count !== 1 ? 's' : ''}</span>`;
}
sw.slots.register('statusbar', {
id: 'tasks-open-count',
component: TaskStatusWidget,
priority: 50,
});
// ── Mount ──────────────────────────────────
render(html`<${TaskApp} />`, mount);
})();

View File

@@ -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"
}
}
}

View File

@@ -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"}}

View File

@@ -213,66 +213,150 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
// POST /api/v1/admin/packages/install // POST /api/v1/admin/packages/install
// //
// Also supports pre-downloaded files via gin context: // Also supports pre-downloaded files via gin context:
//
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload // c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
// c.Set("_registry_source", "registry") — override source // c.Set("_registry_source", "registry") — override source
func (h *PackageHandler) InstallPackage(c *gin.Context) { func (h *PackageHandler) InstallPackage(c *gin.Context) {
var tmpPath string // Phase 1: Receive upload → temp file
var cleanupTmp bool tmpPath, cleanupTmp, err := h.receiveUpload(c)
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 { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) return // error already sent
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()
} }
if cleanupTmp { if cleanupTmp {
defer os.Remove(tmpPath) 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) zr, err := zip.OpenReader(tmpPath)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return return nil, nil, nil, err
} }
defer zr.Close()
// Find and parse manifest.json // Find and parse manifest.json
var manifest map[string]any var manifest map[string]any
@@ -289,19 +373,20 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
continue continue
} }
if err := json.Unmarshal(data, &manifest); err != nil { if err := json.Unmarshal(data, &manifest); err != nil {
zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
return return nil, nil, nil, err
} }
break break
} }
} }
if manifest == nil { if manifest == nil {
zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"}) 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" { if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" {
entryPoint := "script.star" entryPoint := "script.star"
if ep, ok := manifest["entry_point"].(string); ok && ep != "" { if ep, ok := manifest["entry_point"].(string); ok && ep != "" {
@@ -316,13 +401,13 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
} }
} }
if !found { if !found {
zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)}) 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 // Unicode security scan
// characters before writing anything to the extension store.
scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true} scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
for _, f := range zr.File { for _, f := range zr.File {
if f.FileInfo().IsDir() { if f.FileInfo().IsDir() {
@@ -344,43 +429,41 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
findings := sandbox.ScanSource(string(data), f.Name) findings := sandbox.ScanSource(string(data), f.Name)
if len(findings) > 0 { if len(findings) > 0 {
if blocked, reason := sandbox.Verdict(findings); blocked { if blocked, reason := sandbox.Verdict(findings); blocked {
zr.Close()
c.JSON(http.StatusUnprocessableEntity, gin.H{ c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "extension_blocked", "error": "extension_blocked",
"reason": fmt.Sprintf("%s (file: %s)", reason, f.Name), "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) mInfo, err := ValidateManifest(manifest)
if err != nil { if err != nil {
zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 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 os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
if mInfo.Signature != "" { 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 { } 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 return zr, manifest, mInfo, nil
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}) // extractPackageAssets extracts js/, css/, assets/, star/ files to the packages directory.
func (h *PackageHandler) extractPackageAssets(pkgID string, zr *zip.ReadCloser) {
if h.packagesDir == "" {
return return
} }
// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
if h.packagesDir != "" {
destDir := filepath.Join(h.packagesDir, pkgID) destDir := filepath.Join(h.packagesDir, pkgID)
os.MkdirAll(destDir, 0755) os.MkdirAll(destDir, 0755)
@@ -388,21 +471,15 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
if f.FileInfo().IsDir() { if f.FileInfo().IsDir() {
continue continue
} }
relPath := extractableRelPath(f.Name) relPath := extractableRelPath(f.Name)
if relPath == "" { if relPath == "" {
continue continue
} }
destPath := filepath.Join(destDir, relPath) destPath := filepath.Join(destDir, relPath)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) { if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
continue continue // path traversal
} }
os.MkdirAll(filepath.Dir(destPath), 0755) os.MkdirAll(filepath.Dir(destPath), 0755)
rc, err := f.Open() rc, err := f.Open()
if err != nil { if err != nil {
continue continue
@@ -416,44 +493,30 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
out.Close() out.Close()
rc.Close() rc.Close()
} }
log.Printf("[packages] Extracted %s to %s", pkgID, destDir) log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
} }
// Use validated manifest fields for DB columns // registerPackage seeds the package in the database and updates metadata fields.
version := mInfo.Version // Returns the preserved enabled state and any error (already sent to client).
description := mInfo.Description func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *ManifestInfo, pkgSource, userID string, manifest map[string]any) (bool, error) {
author := mInfo.Author if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, mInfo.Title, pkgSource, manifest); err != nil {
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"}) 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 preservedEnabled := true
if existing != nil { if existing != nil {
preservedEnabled = existing.Enabled preservedEnabled = existing.Enabled
} }
// Update fields that Seed doesn't set (type, version, author, etc.)
pkg := &store.PackageRegistration{ pkg := &store.PackageRegistration{
Title: title, Title: mInfo.Title,
Type: pkgType, Type: mInfo.Type,
Version: version, Version: mInfo.Version,
Description: description, Description: mInfo.Description,
Author: author, Author: mInfo.Author,
Tier: tier, Tier: mInfo.Tier,
IsSystem: false, IsSystem: false,
Enabled: preservedEnabled, Enabled: preservedEnabled,
Manifest: manifest, 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 { 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) 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 tables, ok := ParseDBTables(manifest); ok {
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil { 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) 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) SyncManifestPermissions(c, h.stores, pkgID, manifest)
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest) SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)
newSchemaVersion := ParseSchemaVersion(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", "error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
oldSchemaVersion, newSchemaVersion), oldSchemaVersion, newSchemaVersion),
}) })
return return fmt.Errorf("downgrade")
} }
if newSchemaVersion > oldSchemaVersion { if newSchemaVersion > oldSchemaVersion {
if err := RunSchemaMigrations( if err := RunSchemaMigrations(
@@ -500,26 +565,24 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "schema migration failed: " + err.Error(), "error": "schema migration failed: " + err.Error(),
}) })
return return err
} }
} }
} }
return nil
}
// 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
}
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
}
}
// 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 { if h.stores.Dependencies != nil {
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID) h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
} }
for libID, vSpec := range deps { for libID, vSpec := range deps {
lib, err := h.stores.Packages.Get(c.Request.Context(), libID) lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
if err != nil || lib == nil { if err != nil || lib == nil {
@@ -532,24 +595,23 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr), "error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
}) })
return return installErr
} }
// Re-fetch after install
lib, err = h.stores.Packages.Get(c.Request.Context(), libID) lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
} }
} }
if lib == nil { if lib == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"}) c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
return return fmt.Errorf("missing dep: %s", libID)
} }
} }
if lib.Type != "library" { if lib.Type != "library" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID}) c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
return return fmt.Errorf("not a library: %s", libID)
} }
if lib.Status == "suspended" || lib.Status == "dormant" { if lib.Status == "suspended" || lib.Status == "dormant" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID}) c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
return return fmt.Errorf("dep %s is %s", libID, lib.Status)
} }
versionSpec, _ := vSpec.(string) versionSpec, _ := vSpec.(string)
if versionSpec == "" { if versionSpec == "" {
@@ -564,25 +626,16 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}); err != nil { }); err != nil {
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err) 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
return return err
} }
} }
} }
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps)) log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
} return nil
}
if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" { // checkCapabilities marks a package dormant if it declares unmet `requires` entries.
if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil { func (h *PackageHandler) checkCapabilities(c *gin.Context, pkgID string, manifest map[string]any, preservedEnabled *bool) bool {
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)
}
}
}
// Known capabilities: (none yet — chat and legacy-sdk are future/removed).
knownCaps := map[string]bool{} knownCaps := map[string]bool{}
var unmetReqs []string var unmetReqs []string
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 { 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 len(unmetReqs) == 0 {
if isDormant { return false
}
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant") h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false) h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
preservedEnabled = false *preservedEnabled = false
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs) log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
} return true
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)
} }
// extractableRelPath returns the relative path for a zip entry if it // extractableRelPath returns the relative path for a zip entry if it

View File

@@ -25,6 +25,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"armature/config" "armature/config"
"armature/models"
"armature/store" "armature/store"
) )
@@ -631,18 +632,20 @@ func (e *Engine) RenderLogin() gin.HandlerFunc {
// WorkflowPageData is passed to the workflow.html template via PageData.Data. // WorkflowPageData is passed to the workflow.html template via PageData.Data.
type WorkflowPageData struct { type WorkflowPageData struct {
ChannelID string EntryToken string
ChannelTitle string WorkflowTitle string
ChannelDescription string WorkflowDescription string
SessionID string SessionID string
SessionName string SessionName string
StageMode string // custom | form_only | form_chat | review StageMode string // form | review | delegated | automated
StageName string StageName string
FormTemplateJSON string // typed form template JSON (empty if custom) FormTemplateJSON string // typed form template JSON (empty if delegated)
TotalStages int TotalStages int
CurrentStage int CurrentStage int
SurfacePkgID string SurfacePkgID string
BrandingJSON 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. // WorkflowLandingPageData is passed to workflow-landing.html.
@@ -660,40 +663,95 @@ type WorkflowLandingPageData struct {
PersonaName string PersonaName string
PersonaIcon string PersonaIcon string
StageCount int 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 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. // The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc { func (e *Engine) RenderWorkflow() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// :id is the public entry token (from /w/:id) ctx := c.Request.Context()
channelID := c.Param("id") routeID := c.Param("id")
sessionID := c.GetString("session_id") sessionID := c.GetString("session_id")
var title, description string
if title == "" {
title = "Workflow"
}
// Load session display name
sessionName := "Visitor" 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() 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{ e.Render(c, "workflow.html", PageData{
Surface: "workflow", Surface: "workflow",
InstanceName: instanceName, InstanceName: instanceName,
Data: WorkflowPageData{ Data: WorkflowPageData{
ChannelID: channelID, EntryToken: entryToken,
ChannelTitle: title, WorkflowTitle: title,
ChannelDescription: description, WorkflowDescription: description,
SessionID: sessionID, SessionID: sessionID,
SessionName: sessionName, SessionName: sessionName,
StageMode: stageMode, StageMode: stageMode,
@@ -703,6 +761,8 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
CurrentStage: currentStage, CurrentStage: currentStage,
SurfacePkgID: surfacePkgID, SurfacePkgID: surfacePkgID,
BrandingJSON: brandingJSON, BrandingJSON: brandingJSON,
InstanceID: inst.ID,
Status: inst.Status,
}, },
}) })
} }

View File

@@ -146,19 +146,15 @@
<p class="wf-description">{{.Data.Description}}</p> <p class="wf-description">{{.Data.Description}}</p>
{{end}} {{end}}
{{if and .Data.PersonaName (ne .Data.FirstStageMode "form_only")}} {{if and .Data.PersonaName (ne .Data.FirstStageMode "form")}}
<div class="wf-persona"> <div class="wf-persona">
<div class="wf-persona-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div> <div class="wf-persona-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div>
{{if eq .Data.FirstStageMode "form_chat"}} <span>You'll be working with <strong>{{.Data.PersonaName}}</strong></span>
<span>Fill out a form and chat with <strong>{{.Data.PersonaName}}</strong></span>
{{else}}
<span>You'll be chatting with <strong>{{.Data.PersonaName}}</strong></span>
{{end}}
</div> </div>
{{end}} {{end}}
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()"> <button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
{{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}}
</button> </button>
{{if .Data.ResumeURL}} {{if .Data.ResumeURL}}

View File

@@ -4,7 +4,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Data.ChannelTitle}} — {{.InstanceName}}</title> <title>{{.Data.WorkflowTitle}} — {{.InstanceName}}</title>
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}"> <link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}"> <link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
@@ -93,52 +93,22 @@
.wf-form-success h3 { font-size: 18px; margin-bottom: 8px; } .wf-form-success h3 { font-size: 18px; margin-bottom: 8px; }
.wf-form-success p { color: var(--text-2); } .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 { .wf-session-info {
padding: 8px 24px; font-size: 12px; color: var(--text-3); padding: 8px 24px; font-size: 12px; color: var(--text-3);
border-top: 1px solid var(--border); background: var(--bg); border-top: 1px solid var(--border); background: var(--bg);
} }
/* ── Form+Chat layout ───────────── */ /* ── Unsupported mode fallback ─── */
.wf-body-split { display: flex; flex: 1; overflow: hidden; } .wf-unsupported {
.wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; } flex: 1; display: flex; align-items: center; justify-content: center;
.wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; } padding: 24px; text-align: center; color: var(--text-2);
.wf-body-split .wf-chat { flex: 1; } }
/* ── Branding (v0.35.0) ────────── */ /* ── Branding ────────────────────── */
.wf-branding-logo { max-height: 32px; margin-bottom: 4px; } .wf-branding-logo { max-height: 32px; margin-bottom: 4px; }
.wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; } .wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; }
/* ── Progressive Forms (v0.35.0) ── */ /* ── Progressive Forms ────────── */
.wf-fieldset-nav { .wf-fieldset-nav {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; padding-bottom: 12px; margin-bottom: 16px; padding-bottom: 12px;
@@ -175,8 +145,8 @@
<body> <body>
<div class="wf-shell"> <div class="wf-shell">
<div class="wf-header" id="wfHeader"> <div class="wf-header" id="wfHeader">
<h1>{{.Data.ChannelTitle}}</h1> <h1>{{.Data.WorkflowTitle}}</h1>
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}} {{if .Data.WorkflowDescription}}<p>{{.Data.WorkflowDescription}}</p>{{end}}
</div> </div>
{{if .Data.StageName}} {{if .Data.StageName}}
@@ -188,62 +158,55 @@
</div> </div>
{{end}} {{end}}
<!-- Stage surface mount point (v0.30.2) --> <!-- Stage surface: form, review, delegated (custom surface), or automated -->
<!-- Modes: form_only, form_chat, chat_only, review, or custom surface via SurfacePkgID -->
{{if .Data.SurfacePkgID}} {{if .Data.SurfacePkgID}}
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div> <div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
{{else if eq .Data.StageMode "form_only"}} {{else if eq .Data.StageMode "form"}}
<div class="wf-form" id="formArea"></div> <div class="wf-form" id="formArea"></div>
{{else if eq .Data.StageMode "form_chat"}}
<div class="wf-body-split">
<div class="wf-form" id="formArea"></div>
<div class="wf-chat-col">
<div class="wf-chat" id="chatMessages"></div>
<div class="wf-input">
<textarea id="chatInput" placeholder="Type a message…" rows="1"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
<button id="sendBtn" onclick="sendMessage()">Send</button>
</div>
</div>
</div>
{{else if eq .Data.StageMode "review"}} {{else if eq .Data.StageMode "review"}}
<div id="reviewArea" style="flex:1;overflow-y:auto;display:flex"> <div id="reviewArea" style="flex:1;overflow-y:auto;display:flex">
<div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div> <div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div>
<div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div> <div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div>
</div> </div>
{{else if eq .Data.Status "completed"}}
<div class="wf-form-success" style="flex:1;display:flex;flex-direction:column;justify-content:center">
<h3>Completed</h3>
<p>This workflow has been completed. Thank you for your submission.</p>
</div>
{{else}} {{else}}
<div class="wf-chat" id="chatMessages"></div> <div class="wf-unsupported">
<div class="wf-input"> <p>This stage type ({{.Data.StageMode}}) does not have an interactive surface.</p>
<textarea id="chatInput" placeholder="Type a message…" rows="1"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
<button id="sendBtn" onclick="sendMessage()">Send</button>
</div> </div>
{{end}} {{end}}
<div class="wf-session-info"> <div class="wf-session-info">
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form_only"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Chatting as{{end}} <strong>{{.Data.SessionName}}</strong> {{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Session:{{end}} <strong>{{.Data.SessionName}}</strong>
</div> </div>
</div> </div>
<script> <script>
(function() { (function() {
const BASE = '{{.BasePath}}'; const BASE = '{{.BasePath}}';
const CHAN_ID = '{{.Data.ChannelID}}'; const INSTANCE_ID = '{{.Data.InstanceID}}';
const ENTRY_TOKEN = '{{.Data.EntryToken}}';
const SESSION_ID = '{{.Data.SessionID}}'; const SESSION_ID = '{{.Data.SessionID}}';
const STAGE_MODE = '{{.Data.StageMode}}'; const STAGE_MODE = '{{.Data.StageMode}}';
const TOTAL_STAGES = {{.Data.TotalStages}}; const TOTAL_STAGES = {{.Data.TotalStages}};
const CURRENT_STAGE = {{.Data.CurrentStage}}; const CURRENT_STAGE = {{.Data.CurrentStage}};
const SURFACE_PKG_ID = '{{.Data.SurfacePkgID}}'; const SURFACE_PKG_ID = '{{.Data.SurfacePkgID}}';
const STATUS = '{{.Data.Status}}';
// API calls use the entry token (public) or instance ID
const API_ID = ENTRY_TOKEN || INSTANCE_ID;
var FORM_TPL = null; var FORM_TPL = null;
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {} try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
// v0.35.0: Branding
var BRANDING = null; var BRANDING = null;
try { BRANDING = JSON.parse('{{.Data.BrandingJSON}}' || 'null'); } catch(e) {} try { BRANDING = JSON.parse('{{.Data.BrandingJSON}}' || 'null'); } catch(e) {}
// v0.35.0: Apply branding // Apply branding
(function() { (function() {
if (!BRANDING) return; if (!BRANDING) return;
var header = document.getElementById('wfHeader'); var header = document.getElementById('wfHeader');
@@ -279,11 +242,11 @@
} }
})(); })();
// ── Form rendering (v0.35.0: progressive forms + conditional fields) ── // ── Form rendering (progressive forms + conditional fields) ──
var _fieldsetIndex = 0; var _fieldsetIndex = 0;
var _fieldsetCount = 0; var _fieldsetCount = 0;
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL) { if (STAGE_MODE === 'form' && FORM_TPL) {
var formArea = document.getElementById('formArea'); var formArea = document.getElementById('formArea');
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) { if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
renderProgressiveForm(formArea, FORM_TPL); renderProgressiveForm(formArea, FORM_TPL);
@@ -326,7 +289,6 @@
html += '</div></div>'; html += '</div></div>';
} }
container.innerHTML = html; container.innerHTML = html;
// Apply conditional fields for all fieldsets
for (var s = 0; s < tpl.fieldsets.length; s++) { for (var s = 0; s < tpl.fieldsets.length; s++) {
applyConditionalFields(tpl.fieldsets[s].fields); applyConditionalFields(tpl.fieldsets[s].fields);
} }
@@ -350,7 +312,7 @@
var handler = function() { evaluateCondition(field); }; var handler = function() { evaluateCondition(field); };
srcEl.addEventListener('change', handler); srcEl.addEventListener('change', handler);
srcEl.addEventListener('input', handler); srcEl.addEventListener('input', handler);
handler(); // initial evaluation handler();
})(f); })(f);
} }
} }
@@ -436,7 +398,7 @@
} }
window.submitForm = async function() { window.submitForm = async function() {
if (!FORM_TPL || !FORM_TPL.fields) return; if (!FORM_TPL || (!FORM_TPL.fields && !FORM_TPL.fieldsets)) return;
var btn = document.getElementById('formSubmitBtn'); var btn = document.getElementById('formSubmitBtn');
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
@@ -448,7 +410,7 @@
el.textContent = ''; el.textContent = '';
}); });
// Collect form data (v0.35.0: get all fields from fieldsets or top-level) // Collect form data (get all fields from fieldsets or top-level)
var allFields = FORM_TPL.fields || []; var allFields = FORM_TPL.fields || [];
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) { if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
allFields = []; allFields = [];
@@ -461,7 +423,7 @@
var f = allFields[i]; var f = allFields[i];
var el = document.getElementById('ff_' + f.key); var el = document.getElementById('ff_' + f.key);
if (!el) continue; if (!el) continue;
// v0.35.0: Skip hidden conditional fields // Skip hidden conditional fields
var fieldWrap = document.querySelector('.wf-form-field[data-key="' + f.key + '"]'); var fieldWrap = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
if (fieldWrap && fieldWrap.classList.contains('cond-hidden')) continue; if (fieldWrap && fieldWrap.classList.contains('cond-hidden')) continue;
if (f.type === 'checkbox') { if (f.type === 'checkbox') {
@@ -474,7 +436,7 @@
} }
try { try {
var resp = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, { var resp = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: data }), body: JSON.stringify({ data: data }),
@@ -490,16 +452,13 @@
return; return;
} }
// Success // Success — API returns instance status: "active" (advanced) or "completed"
if (result.status === 'advanced' || result.status === 'completed') { if (result.status === 'completed') {
showFormSuccess(result.status === 'completed' showFormSuccess('Thank you! Your submission is complete.');
? 'Thank you! Your submission is complete.'
: 'Submitted! Moving to the next step...');
if (result.status === 'advanced') {
setTimeout(function() { window.location.reload(); }, 1500);
}
} else { } else {
showFormSuccess('Your response has been submitted.'); // Still active = advanced to next stage — reload to render it
showFormSuccess('Submitted! Moving to the next step...');
setTimeout(function() { window.location.reload(); }, 1500);
} }
} catch(e) { } catch(e) {
alert('Network error: ' + e.message); alert('Network error: ' + e.message);
@@ -524,22 +483,6 @@
} }
} }
// ── Chat ───────────────────────────
var chatEl = document.getElementById('chatMessages');
var inputEl = document.getElementById('chatInput');
var sendBtn = document.getElementById('sendBtn');
var sending = false;
function addMessage(role, content, name) {
if (!chatEl) return;
var div = document.createElement('div');
div.className = 'message ' + role;
var meta = name ? '<div class="meta">' + escHtml(name) + '</div>' : '';
div.innerHTML = meta + '<div class="content">' + escHtml(content) + '</div>';
chatEl.appendChild(div);
chatEl.scrollTop = chatEl.scrollHeight;
}
function escHtml(s) { function escHtml(s) {
var d = document.createElement('div'); var d = document.createElement('div');
d.textContent = s; d.textContent = s;
@@ -550,46 +493,7 @@
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
window.sendMessage = async function() { // ── Custom surface (delegated mode) ──────────
if (!inputEl || !sendBtn) return;
var text = inputEl.value.trim();
if (!text || sending) return;
sending = true;
sendBtn.disabled = true;
inputEl.value = '';
addMessage('user', text, '{{.Data.SessionName}}');
try {
// TODO(v0.8+): form_chat completions endpoint not yet implemented
var resp = { ok: false, statusText: 'Chat mode not yet available', json: function() { return Promise.resolve({ error: 'Chat mode not yet available' }); } };
if (resp.ok) {
var data = await resp.json();
if (data.content) {
addMessage('assistant', data.content, data.persona_name || 'Assistant');
}
} else {
var err = await resp.json().catch(function() { return {}; });
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
}
} catch(e) {
addMessage('assistant', 'Network error: ' + e.message);
}
sending = false;
sendBtn.disabled = false;
inputEl.focus();
};
// Auto-resize textarea
if (inputEl) {
inputEl.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
});
}
// ── Custom surface (v0.37.14: legacy registry removed) ──────────
if (SURFACE_PKG_ID) { if (SURFACE_PKG_ID) {
var mount = document.getElementById('customSurfaceMount'); var mount = document.getElementById('customSurfaceMount');
if (mount) { if (mount) {
@@ -607,7 +511,7 @@
}); });
} }
// ── Review surface (v0.35.0: structured review with side-by-side + comments) ── // ── Review surface (structured review with side-by-side + comments) ──
if (STAGE_MODE === 'review') { if (STAGE_MODE === 'review') {
var dataPanel = document.getElementById('reviewDataPanel'); var dataPanel = document.getElementById('reviewDataPanel');
var actionPanel = document.getElementById('reviewActionPanel'); var actionPanel = document.getElementById('reviewActionPanel');
@@ -624,7 +528,7 @@
html += '<h3 style="margin-bottom:16px">Collected Data</h3>'; html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
try { try {
var resp = await fetch(BASE + '/api/v1/public/workflows/resume/' + CHAN_ID, { var resp = await fetch(BASE + '/api/v1/public/workflows/resume/' + API_ID, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
if (resp.ok) { if (resp.ok) {
@@ -666,7 +570,7 @@
actHtml += '</div>'; actHtml += '</div>';
actionPanel.innerHTML = actHtml; actionPanel.innerHTML = actHtml;
// Keyboard shortcuts (v0.35.0) // Keyboard shortcuts
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') { if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@@ -681,7 +585,7 @@
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() { document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
this.disabled = true; this.disabled = true;
try { try {
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, { var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: {} }), body: JSON.stringify({ data: {} }),
}); });
@@ -702,7 +606,7 @@
if (!reason) return; if (!reason) return;
this.disabled = true; this.disabled = true;
try { try {
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + CHAN_ID, { var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { _action: 'reject', reason: reason } }), body: JSON.stringify({ data: { _action: 'reject', reason: reason } }),
}); });

View File

@@ -1777,7 +1777,7 @@ paths:
enum: [full, summary, fresh] enum: [full, summary, fresh]
stage_mode: stage_mode:
type: string type: string
enum: [custom, form_only, form_chat, review] enum: [form, review, delegated, automated]
form_template: form_template:
type: object type: object
transition_rules: transition_rules:
@@ -1817,7 +1817,7 @@ paths:
enum: [full, summary, fresh] enum: [full, summary, fresh]
stage_mode: stage_mode:
type: string type: string
enum: [custom, form_only, form_chat, review] enum: [form, review, delegated, automated]
form_template: form_template:
type: object type: object
transition_rules: transition_rules:
@@ -3209,7 +3209,7 @@ paths:
enum: [full, summary, fresh] enum: [full, summary, fresh]
stage_mode: stage_mode:
type: string type: string
enum: [custom, form_only, form_chat, review] enum: [form, review, delegated, automated]
form_template: form_template:
type: object type: object
transition_rules: transition_rules:
@@ -3250,7 +3250,7 @@ paths:
enum: [full, summary, fresh] enum: [full, summary, fresh]
stage_mode: stage_mode:
type: string type: string
enum: [custom, form_only, form_chat, review] enum: [form, review, delegated, automated]
form_template: form_template:
type: object type: object
transition_rules: transition_rules:

View File

@@ -0,0 +1,629 @@
package sqlite
import (
"context"
"encoding/json"
"os"
"testing"
"armature/database"
"armature/models"
"armature/store"
)
func TestMain(m *testing.M) {
os.Setenv("DB_DRIVER", "sqlite")
teardown := database.SetupTestDB()
code := m.Run()
teardown()
os.Exit(code)
}
func resetDB(t *testing.T) {
t.Helper()
database.TruncateAll(t)
SetDB(database.TestDB)
}
// newWF creates a workflow with required defaults filled in.
func newWF(name, slug, createdBy string) *models.Workflow {
return &models.Workflow{
Name: name,
Slug: slug,
EntryMode: "public_link",
CreatedBy: createdBy,
}
}
// ── Workflow CRUD ───────────────────────────────
func TestWorkflowCreate(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Bug Report", "bug-report", userID)
wf.Description = "Report a bug"
wf.IsActive = true
if err := s.Create(ctx, wf); err != nil {
t.Fatalf("Create: %v", err)
}
if wf.ID == "" {
t.Fatal("expected ID to be assigned")
}
if wf.Version != 1 {
t.Fatalf("expected version=1, got %d", wf.Version)
}
}
func TestWorkflowGetByID(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Test WF", "test-wf", userID)
wf.IsActive = true
if err := s.Create(ctx, wf); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := s.GetByID(ctx, wf.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.Name != "Test WF" {
t.Fatalf("expected name 'Test WF', got %q", got.Name)
}
}
func TestWorkflowGetBySlug(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Slug Test", "slug-test", userID)
s.Create(ctx, wf)
got, err := s.GetBySlug(ctx, nil, "slug-test")
if err != nil {
t.Fatalf("GetBySlug: %v", err)
}
if got == nil || got.ID != wf.ID {
t.Fatal("expected to find workflow by slug")
}
got, _ = s.GetBySlug(ctx, nil, "nope")
if got != nil {
t.Fatal("expected nil for non-existent slug")
}
}
func TestWorkflowUpdate(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Original", "original", userID)
s.Create(ctx, wf)
newName := "Updated"
if err := s.Update(ctx, wf.ID, models.WorkflowPatch{Name: &newName}); err != nil {
t.Fatalf("Update: %v", err)
}
got, _ := s.GetByID(ctx, wf.ID)
if got.Name != "Updated" {
t.Fatalf("expected name 'Updated', got %q", got.Name)
}
if got.Version != 2 {
t.Fatalf("expected version=2, got %d", got.Version)
}
}
func TestWorkflowDelete(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Doomed", "doomed", userID)
s.Create(ctx, wf)
if err := s.Delete(ctx, wf.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
got, _ := s.GetByID(ctx, wf.ID)
if got != nil {
t.Fatal("expected nil after delete")
}
}
func TestWorkflowListGlobal(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
s.Create(ctx, newWF("A", "a", userID))
s.Create(ctx, newWF("B", "b", userID))
list, err := s.ListGlobal(ctx)
if err != nil {
t.Fatalf("ListGlobal: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 workflows, got %d", len(list))
}
}
// ── Stage CRUD ──────────────────────────────────
func TestStageCRUD(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Staged", "staged", userID)
s.Create(ctx, wf)
stage1 := &models.WorkflowStage{
WorkflowID: wf.ID,
Ordinal: 0,
Name: "Intake",
StageMode: "form",
FormTemplate: json.RawMessage(`{"fields":[{"key":"name","type":"text","label":"Name"}]}`),
}
if err := s.CreateStage(ctx, stage1); err != nil {
t.Fatalf("CreateStage: %v", err)
}
stage2 := &models.WorkflowStage{
WorkflowID: wf.ID,
Ordinal: 1,
Name: "Review",
StageMode: "review",
}
s.CreateStage(ctx, stage2)
stages, err := s.ListStages(ctx, wf.ID)
if err != nil {
t.Fatalf("ListStages: %v", err)
}
if len(stages) != 2 {
t.Fatalf("expected 2 stages, got %d", len(stages))
}
if stages[0].Name != "Intake" || stages[0].StageMode != "form" {
t.Fatalf("stage 0: got %s/%s", stages[0].Name, stages[0].StageMode)
}
if stages[1].Name != "Review" || stages[1].StageMode != "review" {
t.Fatalf("stage 1: got %s/%s", stages[1].Name, stages[1].StageMode)
}
// Update
stage1.Name = "Updated Intake"
if err := s.UpdateStage(ctx, stage1); err != nil {
t.Fatalf("UpdateStage: %v", err)
}
stages, _ = s.ListStages(ctx, wf.ID)
if stages[0].Name != "Updated Intake" {
t.Fatalf("expected updated name, got %q", stages[0].Name)
}
// Delete
if err := s.DeleteStage(ctx, stage2.ID); err != nil {
t.Fatalf("DeleteStage: %v", err)
}
stages, _ = s.ListStages(ctx, wf.ID)
if len(stages) != 1 {
t.Fatalf("expected 1 stage after delete, got %d", len(stages))
}
}
func TestStageReorder(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Reorder", "reorder", userID)
s.Create(ctx, wf)
s1 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "First", StageMode: "form"}
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "review"}
s.CreateStage(ctx, s1)
s.CreateStage(ctx, s2)
if err := s.ReorderStages(ctx, wf.ID, []string{s2.ID, s1.ID}); err != nil {
t.Fatalf("ReorderStages: %v", err)
}
stages, _ := s.ListStages(ctx, wf.ID)
if stages[0].Name != "Second" || stages[1].Name != "First" {
t.Fatal("expected reversed order after reorder")
}
}
// ── Instance Lifecycle ──────────────────────────
func TestInstanceLifecycle(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Lifecycle", "lifecycle", userID)
wf.IsActive = true
s.Create(ctx, wf)
stage := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "Step 1", StageMode: "form"}
s.CreateStage(ctx, stage)
token := "test-token-123"
inst := &models.WorkflowInstance{
WorkflowID: wf.ID,
WorkflowVersion: 1,
CurrentStage: stage.ID,
Status: "active",
StartedBy: userID,
EntryToken: &token,
StageData: json.RawMessage(`{}`),
Metadata: json.RawMessage(`{}`),
}
if err := s.CreateInstance(ctx, inst); err != nil {
t.Fatalf("CreateInstance: %v", err)
}
// GetInstance
got, err := s.GetInstance(ctx, inst.ID)
if err != nil {
t.Fatalf("GetInstance: %v", err)
}
if got.Status != "active" {
t.Fatalf("expected 'active', got %q", got.Status)
}
// GetInstanceByToken
got, err = s.GetInstanceByToken(ctx, "test-token-123")
if err != nil {
t.Fatalf("GetInstanceByToken: %v", err)
}
if got.ID != inst.ID {
t.Fatal("token lookup returned wrong instance")
}
// AdvanceStage
if err := s.AdvanceStage(ctx, inst.ID, "step-2", json.RawMessage(`{"key":"value"}`)); err != nil {
t.Fatalf("AdvanceStage: %v", err)
}
got, _ = s.GetInstance(ctx, inst.ID)
if got.CurrentStage != "step-2" {
t.Fatalf("expected 'step-2', got %q", got.CurrentStage)
}
// CompleteInstance
if err := s.CompleteInstance(ctx, inst.ID); err != nil {
t.Fatalf("CompleteInstance: %v", err)
}
got, _ = s.GetInstance(ctx, inst.ID)
if got.Status != "completed" {
t.Fatalf("expected 'completed', got %q", got.Status)
}
}
func TestInstanceCancel(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Cancel", "cancel", userID)
s.Create(ctx, wf)
inst := &models.WorkflowInstance{
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
Status: "active", StartedBy: userID,
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
}
s.CreateInstance(ctx, inst)
if err := s.CancelInstance(ctx, inst.ID); err != nil {
t.Fatalf("CancelInstance: %v", err)
}
got, _ := s.GetInstance(ctx, inst.ID)
if got.Status != "cancelled" {
t.Fatalf("expected 'cancelled', got %q", got.Status)
}
}
func TestInstanceStale(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Stale", "stale", userID)
s.Create(ctx, wf)
inst := &models.WorkflowInstance{
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
Status: "active", StartedBy: userID,
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
}
s.CreateInstance(ctx, inst)
if err := s.MarkInstanceStale(ctx, inst.ID); err != nil {
t.Fatalf("MarkInstanceStale: %v", err)
}
got, _ := s.GetInstance(ctx, inst.ID)
if got.Status != "stale" {
t.Fatalf("expected 'stale', got %q", got.Status)
}
}
func TestListInstances(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("List", "list", userID)
s.Create(ctx, wf)
for i := 0; i < 3; i++ {
inst := &models.WorkflowInstance{
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
Status: "active", StartedBy: userID,
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
}
s.CreateInstance(ctx, inst)
}
list, err := s.ListInstances(ctx, wf.ID, "", store.ListOptions{Limit: 10})
if err != nil {
t.Fatalf("ListInstances: %v", err)
}
if len(list) != 3 {
t.Fatalf("expected 3, got %d", len(list))
}
list, _ = s.ListInstances(ctx, wf.ID, "completed", store.ListOptions{Limit: 10})
if len(list) != 0 {
t.Fatalf("expected 0 completed, got %d", len(list))
}
}
// ── Team-scoped ─────────────────────────────────
func TestWorkflowTeamScope(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
teamID := database.SeedTestTeam(t, "TestTeam", userID)
wf := newWF("Team WF", "team-wf", userID)
wf.TeamID = &teamID
s.Create(ctx, wf)
got, err := s.GetBySlug(ctx, &teamID, "team-wf")
if err != nil || got == nil {
t.Fatalf("GetBySlug(team): %v", err)
}
if got.ID != wf.ID {
t.Fatal("wrong workflow")
}
list, _ := s.ListForTeam(ctx, teamID)
if len(list) != 1 {
t.Fatalf("expected 1 team workflow, got %d", len(list))
}
global, _ := s.ListGlobal(ctx)
if len(global) != 0 {
t.Fatalf("expected 0 global workflows, got %d", len(global))
}
}
// ── API Token Store ─────────────────────────────
func TestAPITokenCRUD(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewAPITokenStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "tokenuser", "tokenuser@test.com")
tok := &models.APIToken{
UserID: userID,
Name: "CI Token",
TokenHash: "sha256_abc123",
Permissions: []string{"extension.use"},
}
if err := s.Create(ctx, tok); err != nil {
t.Fatalf("Create: %v", err)
}
if tok.ID == "" {
t.Fatal("expected ID")
}
// ListForUser
list, err := s.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("ListForUser: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 token, got %d", len(list))
}
if list[0].Name != "CI Token" {
t.Fatalf("expected 'CI Token', got %q", list[0].Name)
}
// GetByHash
got, err := s.GetByHash(ctx, "sha256_abc123")
if err != nil {
t.Fatalf("GetByHash: %v", err)
}
if got.ID != tok.ID {
t.Fatal("hash lookup returned wrong token")
}
// Revoke
if _, err := s.Revoke(ctx, tok.ID, userID); err != nil {
t.Fatalf("Revoke: %v", err)
}
list, _ = s.ListForUser(ctx, userID)
if len(list) != 0 {
t.Fatalf("expected 0 tokens after revoke, got %d", len(list))
}
}
// ── User Store ──────────────────────────────────
func TestUserGetByID(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewUserStore()
ctx := context.Background()
u := &models.User{
Username: "testuser",
Email: "test@example.com",
PasswordHash: "$2a$10$dummy",
DisplayName: "Test User",
IsActive: true,
}
if err := s.Create(ctx, u); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := s.GetByID(ctx, u.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.Username != "testuser" {
t.Fatalf("expected 'testuser', got %q", got.Username)
}
if got.DisplayName != "Test User" {
t.Fatalf("expected 'Test User', got %q", got.DisplayName)
}
}
func TestUserGetByLogin(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewUserStore()
ctx := context.Background()
u := &models.User{
Username: "logintest",
Email: "login@test.com",
PasswordHash: "$2a$10$dummy",
IsActive: true,
}
s.Create(ctx, u)
// By username
got, err := s.GetByLogin(ctx, "logintest")
if err != nil {
t.Fatalf("GetByLogin(username): %v", err)
}
if got.ID != u.ID {
t.Fatal("wrong user by username")
}
// By email
got, err = s.GetByLogin(ctx, "login@test.com")
if err != nil {
t.Fatalf("GetByLogin(email): %v", err)
}
if got.ID != u.ID {
t.Fatal("wrong user by email")
}
// Case insensitive
got, _ = s.GetByLogin(ctx, "LOGINTEST")
if got == nil || got.ID != u.ID {
t.Fatal("expected case-insensitive match")
}
}
// ── Group Store ─────────────────────────────────
func TestGroupCRUD(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewGroupStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "grpuser", "grpuser@test.com")
g := &models.Group{
Name: "Editors",
Description: "Can edit content",
Scope: "global",
CreatedBy: &userID,
Permissions: []string{"extension.use", "workflow.create"},
}
if err := s.Create(ctx, g); err != nil {
t.Fatalf("Create: %v", err)
}
// GetByID
got, err := s.GetByID(ctx, g.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.Name != "Editors" {
t.Fatalf("expected 'Editors', got %q", got.Name)
}
// ListAll (includes system groups from TruncateAll re-seed + our new group)
list, err := s.ListAll(ctx)
if err != nil {
t.Fatalf("ListAll: %v", err)
}
if len(list) < 3 { // Everyone + Admins + Editors
t.Fatalf("expected ≥3 groups, got %d", len(list))
}
// AddMember / ListMembers
if err := s.AddMember(ctx, g.ID, userID, userID); err != nil {
t.Fatalf("AddMember: %v", err)
}
members, err := s.ListMembers(ctx, g.ID)
if err != nil {
t.Fatalf("ListMembers: %v", err)
}
if len(members) != 1 {
t.Fatalf("expected 1 member, got %d", len(members))
}
// RemoveMember
if err := s.RemoveMember(ctx, g.ID, userID); err != nil {
t.Fatalf("RemoveMember: %v", err)
}
members, _ = s.ListMembers(ctx, g.ID)
if len(members) != 0 {
t.Fatalf("expected 0 members after remove, got %d", len(members))
}
}