Feat v0.7.9 workflow independence #63
47
CHANGELOG.md
47
CHANGELOG.md
@@ -2,6 +2,53 @@
|
||||
|
||||
All notable changes to Armature are documented here.
|
||||
|
||||
## v0.7.9 — Workflow Independence Audit
|
||||
|
||||
Workflows proven independent of chat and all optional packages. Critical
|
||||
rendering bugs fixed, dead chat UI removed, deferred test debt closed.
|
||||
|
||||
**RenderWorkflow Fix (critical)**
|
||||
|
||||
- `RenderWorkflow()` was a stub that never loaded instance/stage data — post-start page was broken. Now loads instance by token/ID, resolves current stage, populates all template fields.
|
||||
- `WorkflowPageData` fields renamed: `ChannelID` → `EntryToken`, `ChannelTitle` → `WorkflowTitle`, `ChannelDescription` → `WorkflowDescription` (vestigial chat-era names)
|
||||
- Stage modes aligned: template uses Go constants (`form`, `review`, `delegated`, `automated`) instead of legacy `form_only`/`form_chat`
|
||||
- Dead chat UI removed: chat CSS, split layout, `sendMessage()`, fallback chat branch (~180 lines deleted from `workflow.html`)
|
||||
- Landing page mode conditionals updated to match Go constants
|
||||
- OpenAPI `stage_mode` enum corrected (4 occurrences)
|
||||
|
||||
**Bug Fixes (found during verification)**
|
||||
|
||||
- Entry token resolution: handler passed route param (instance ID) as entry token to JS. Fixed: resolve actual token from `inst.EntryToken` + `?token=` query param.
|
||||
- Fieldset submit guard: `submitForm()` checked `FORM_TPL.fields` but not `.fieldsets` — progressive forms silently no-op'd. Fixed: accept either.
|
||||
- Stage advance status check: JS checked `result.status === 'advanced'` but API returns `active`/`completed`. Fixed: on 200 OK with `active`, reload to render next stage.
|
||||
|
||||
**Deferred test coverage (carried from v0.7.6)**
|
||||
|
||||
- 17 new SQLite store tests: workflow CRUD, stages, instances, lifecycle (advance/complete/cancel/stale), team scope, API tokens, users, groups
|
||||
- `InstallPackage` decomposed from 400-line monolith into 7 private methods: `receiveUpload`, `parseAndValidateArchive`, `extractPackageAssets`, `registerPackage`, `applySchemaAndPermissions`, `resolveDependencies`, `checkCapabilities`
|
||||
- `ci/e2e-workflow-nochat.sh` — E2E test for workflow lifecycle without chat
|
||||
|
||||
**Discovered issues (deferred to v0.7.10)**
|
||||
|
||||
- Public→authenticated stage handoff: visitor sees auth-gated stage form instead of "submitted" screen
|
||||
- Team member pickup UI: no surface for claiming workflow instances
|
||||
- Assignment flow: no admin UI for manual instance assignment
|
||||
|
||||
**Tests:** 17 new store tests, 1 new E2E script
|
||||
|
||||
---
|
||||
|
||||
## v0.7.8 — Bug Fixes & Admin Gaps
|
||||
|
||||
- `StartBySlug` handler + `/api/v1/workflow-entry/:scope/:slug` route for landing page Start button
|
||||
- Workflow delete guard: admin endpoint rejects team-scoped workflows (403)
|
||||
- Package button cleanup: Delete hidden for bundled packages
|
||||
- Package export: `fetch()` with auth token instead of `window.open()`
|
||||
- Settings CSS: bottom padding fix for save button cutoff
|
||||
- Shared `StageForm` component between admin and team-admin; public entry URL with copy button
|
||||
|
||||
---
|
||||
|
||||
## v0.7.7 — API Tokens + Extension Permissions
|
||||
|
||||
Personal access tokens (PATs) for programmatic API access, plus extension-declared
|
||||
|
||||
65
ROADMAP.md
65
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Armature — Roadmap
|
||||
|
||||
## Current: v0.7.7 — API Tokens + Extension Permissions
|
||||
## Current: v0.7.9 — Workflow Independence Audit
|
||||
|
||||
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||
@@ -223,10 +223,10 @@ a test coverage gap in the store layer. Fix before v0.8.x kernel expansion.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| store/ unit tests | | Deferred to v0.7.8. Direct tests for PG + SQLite store implementations. Requires live DB. Target: ≥30% SLOC ratio. |
|
||||
| store/ unit tests | done | Moved to v0.7.9. 17 SQLite store tests: workflow CRUD, stages, instances, lifecycle, tokens, users, groups. |
|
||||
| workflow/ routing unit tests | done | 82 tests: ResolveNextStage, ResolveStageByName, ParseStageConfig, evaluateCondition (all operators). |
|
||||
| middleware/ unit tests | done | 10 tests: RequirePermission, RequireAdmin, permission caching, RateLimiter (allow/deny/fail-open). |
|
||||
| InstallPackage decomposition | | Deferred to v0.7.8. 224 cognitive complexity. Split into: validate → create → DDL → permissions → triggers. |
|
||||
| InstallPackage decomposition | done | Moved to v0.7.9. Split into 7 phases: receiveUpload, parseAndValidateArchive, extractPackageAssets, registerPackage, applySchemaAndPermissions, resolveDependencies, checkCapabilities. |
|
||||
|
||||
**Bug Fixes**
|
||||
|
||||
@@ -285,16 +285,59 @@ Workflows must work end-to-end without chat or any 3rd-party package
|
||||
dependency. Chat, notifications, and other packages should *enhance*
|
||||
workflows but never be required for core operation.
|
||||
|
||||
**RenderWorkflow Fix (critical)**
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Public entry flow | | Verify workflow start → instance creation → stage progression works without chat package installed. Landing page → form → completion. |
|
||||
| Stage mode degradation | | All stage modes (`form_only`, `form_chat`, `review`) degrade gracefully when optional packages missing. `form_chat` without chat falls back to form-only. |
|
||||
| Instance lifecycle | | Create, advance, complete, cancel — full lifecycle without chat dependency. Engine doesn't error when chat unavailable. |
|
||||
| Admin/team-admin UI | | All CRUD, monitoring, and stage builder work without optional packages. No broken references. |
|
||||
| E2E without chat | | Landing page → instance → stage progression → completion tested with chat package disabled. |
|
||||
| Deferred test coverage | | store/ unit tests (PG + SQLite), InstallPackage decomposition, workflow/ engine integration tests. Carried from v0.7.6. |
|
||||
| Fix RenderWorkflow() handler | done | Was a stub (always `stageMode = "custom"`, no data loaded). Now loads instance by token/ID, resolves current stage, populates all template data. |
|
||||
| Rename WorkflowPageData fields | done | `ChannelID` → `EntryToken`, `ChannelTitle` → `WorkflowTitle`, `ChannelDescription` → `WorkflowDescription`. Vestigial chat-era names removed. |
|
||||
| Align stage mode values | done | Template now uses Go constants (`form`, `review`, `delegated`, `automated`) instead of legacy names (`form_only`, `form_chat`). |
|
||||
| Remove dead chat UI | done | Chat CSS, split layout, `sendMessage()` function, and fallback chat branch all removed from `workflow.html`. Replaced with clean form/review/unsupported modes. |
|
||||
| Fix landing page mode mapping | done | `workflow-landing.html` conditionals updated to match Go stage mode constants. |
|
||||
| Update OpenAPI spec | done | `stage_mode` enum changed from `[custom, form_only, form_chat, review]` to `[form, review, delegated, automated]`. |
|
||||
|
||||
### v0.7.10 — Query & HTTP Ergonomics
|
||||
**Verification & Testing**
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Admin/team-admin UI audit | done | All workflow CRUD, monitoring, stage builder verified clean — zero chat/chat-core references. |
|
||||
| E2E workflow test | done | `ci/e2e-workflow-nochat.sh` — creates workflow via API, adds form+review stages, starts instance via public entry, verifies landing + execution pages. |
|
||||
| Store unit tests (SQLite) | done | 17 tests: workflow CRUD, stages, instances, lifecycle (advance/complete/cancel/stale), team scope, API tokens, users, groups. Carried from v0.7.6. |
|
||||
| InstallPackage decomposition | done | Split 400-line handler into 7 private methods: `receiveUpload`, `parseAndValidateArchive`, `extractPackageAssets`, `registerPackage`, `applySchemaAndPermissions`, `resolveDependencies`, `checkCapabilities`. Carried from v0.7.6. |
|
||||
|
||||
**Bug Fixes (found during verification)**
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Entry token resolution | done | `RenderWorkflow()` passed route param (instance ID) as entry token. JS then sent instance ID to the advance API which expects entry token. Fixed: resolve actual token from `inst.EntryToken` + check `?token=` query param. |
|
||||
| Fieldset submit guard | done | `submitForm()` checked `FORM_TPL.fields` but not `.fieldsets` — progressive forms silently no-op'd on submit. Fixed: accept either. |
|
||||
| Stage advance status check | done | Template JS checked `result.status === 'advanced'` but API returns instance status (`active`/`completed`). Fixed: on 200 OK with `active` status, reload to render next stage. |
|
||||
|
||||
**Discovered issues (deferred)**
|
||||
|
||||
The audit uncovered deeper workflow UX gaps that are out of scope for v0.7.9:
|
||||
|
||||
1. **Public→authenticated stage handoff** — After a public visitor completes their stages, the workflow advances to an authenticated stage (e.g. "classify"). The visitor sees the next stage's form + an auth error instead of a "thank you, we'll take it from here" message. The page should detect audience mismatch and show a completion/waiting screen.
|
||||
2. **Team member pickup UI** — No surface exists for team members to see workflow instances assigned to their team and claim/work on them. Currently only visible in team-admin monitoring.
|
||||
3. **Assignment flow** — No mechanism for team admins to assign a specific instance to a specific team member. The `workflow_assignments` table exists but has no admin UI for manual assignment.
|
||||
|
||||
These will be addressed in a future version (see v0.7.10 below).
|
||||
|
||||
### v0.7.10 — Workflow Handoff + Assignment UI
|
||||
|
||||
Addresses the three UX gaps found during the v0.7.9 independence audit.
|
||||
The workflow engine and data model already support these flows — the
|
||||
missing pieces are page-level audience detection and team-facing UI.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Public stage completion screen | | When `RenderWorkflow()` detects the current stage's audience is `team`/`system` but the visitor is unauthenticated, render a "Submitted — your request is being reviewed" screen instead of the stage form. |
|
||||
| Team workflow inbox | | New section in team-admin (or dedicated surface): list active instances assigned to this team, with claim/unclaim actions. Uses existing `ListAssignmentsByTeam` store method. |
|
||||
| Manual assignment UI | | Team admin can assign an unclaimed instance to a specific team member. Uses existing `CreateAssignment` store method. |
|
||||
| Assignment notifications | | When an instance is assigned (auto or manual), notify the assignee via the notification system. |
|
||||
| E2E multi-stage test | | Full lifecycle: public form → team pickup → review → complete. Verified with both authenticated and unauthenticated users. |
|
||||
|
||||
### v0.7.11 — Query & HTTP Ergonomics (was v0.7.10)
|
||||
|
||||
Four additions to existing sandbox modules. No new files beyond tests,
|
||||
no schema changes. Motivated by real-world extension porting feedback:
|
||||
@@ -309,7 +352,7 @@ remaining friction points for extensions migrating from native Go/Python.
|
||||
| `http.batch()` | | `http.batch(requests)` → list of response dicts (ordered). Each request dict: `method`, `url`, `body`, `headers`. Concurrent dispatch via goroutines capped at 10, `sync.WaitGroup` collection. Reuses existing `httpDo` plumbing (SSRF checks, domain allowlist, body cap, timeout). Individual failures return error response dicts, don't abort batch. Permission: `api.http`. ~80 lines in `http_module.go`. |
|
||||
| Tests | | Unit tests for all four builtins. `db.count`/`db.aggregate` test empty table, filtered, type coercion. `db.query_batch` test mixed specs, empty list. `http.batch` test concurrent execution, partial failure, ordering. PG + SQLite for db tests. |
|
||||
|
||||
### v0.7.11 — Concurrent Execution Primitive
|
||||
### v0.7.12 — Concurrent Execution Primitive (was v0.7.11)
|
||||
|
||||
New `batch` sandbox module. Enables extensions to parallelize arbitrary
|
||||
Starlark callables — including library function calls via `lib.require()`.
|
||||
|
||||
177
ci/e2e-workflow-nochat.sh
Executable file
177
ci/e2e-workflow-nochat.sh
Executable 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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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"}}
|
||||
@@ -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", "")}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
})();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}}
|
||||
@@ -213,66 +213,150 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||
// POST /api/v1/admin/packages/install
|
||||
//
|
||||
// Also supports pre-downloaded files via gin context:
|
||||
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
|
||||
// c.Set("_registry_source", "registry") — override source
|
||||
//
|
||||
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
|
||||
// c.Set("_registry_source", "registry") — override source
|
||||
func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
var tmpPath string
|
||||
var cleanupTmp bool
|
||||
|
||||
if regFile, ok := c.Get("_registry_file"); ok {
|
||||
tmpPath = regFile.(string)
|
||||
// Don't remove — caller manages lifecycle
|
||||
} else {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate extension
|
||||
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
||||
strings.HasSuffix(header.Filename, ".surface") ||
|
||||
strings.HasSuffix(header.Filename, ".zip")
|
||||
if !validExt {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
// Size limit: 50MB
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read into temp file for zip processing
|
||||
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
tmpPath = tmpFile.Name()
|
||||
cleanupTmp = true
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||
return
|
||||
}
|
||||
tmpFile.Close()
|
||||
// Phase 1: Receive upload → temp file
|
||||
tmpPath, cleanupTmp, err := h.receiveUpload(c)
|
||||
if err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
if cleanupTmp {
|
||||
defer os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
// Open as zip
|
||||
// Phase 2: Parse and validate archive
|
||||
zr, manifest, mInfo, err := h.parseAndValidateArchive(c, tmpPath)
|
||||
if err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
defer zr.Close()
|
||||
pkgID := mInfo.ID
|
||||
|
||||
// Phase 3: Check for conflicts with core packages
|
||||
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
if existing != nil && existing.Source == "core" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 4: Extract static assets to disk
|
||||
h.extractPackageAssets(pkgID, zr)
|
||||
|
||||
// Phase 5: Register in database
|
||||
userID := c.GetString("user_id")
|
||||
pkgSource := "extension"
|
||||
if src, ok := c.Get("_registry_source"); ok {
|
||||
pkgSource = src.(string)
|
||||
}
|
||||
preservedEnabled, err := h.registerPackage(c, pkgID, mInfo, pkgSource, userID, manifest)
|
||||
if err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
|
||||
// Phase 6: Apply DDL, permissions, triggers, schema migrations
|
||||
if err := h.applySchemaAndPermissions(c, pkgID, mInfo, manifest, existing); err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
|
||||
// Phase 7: Install workflow definition (if workflow type)
|
||||
if mInfo.Type == "workflow" {
|
||||
if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
|
||||
log.Printf("[packages] workflow install failed for %s: %v", pkgID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 8: Resolve dependencies
|
||||
if err := h.resolveDependencies(c, pkgID, manifest); err != nil {
|
||||
return // error already sent
|
||||
}
|
||||
|
||||
// Phase 9: Auto-set default surface
|
||||
if (mInfo.Type == "surface" || mInfo.Type == "full") && pkgSource != "core" {
|
||||
if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
|
||||
dflt := models.JSONMap{"id": pkgID}
|
||||
if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
|
||||
log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
|
||||
} else {
|
||||
log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 10: Check capabilities → mark dormant if unmet
|
||||
isDormant := h.checkCapabilities(c, pkgID, manifest, &preservedEnabled)
|
||||
|
||||
resp := gin.H{
|
||||
"id": pkgID,
|
||||
"title": mInfo.Title,
|
||||
"type": mInfo.Type,
|
||||
"version": mInfo.Version,
|
||||
"source": pkgSource,
|
||||
"enabled": preservedEnabled,
|
||||
}
|
||||
if isDormant {
|
||||
resp["status"] = "dormant"
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
h.broadcastPackageChanged("installed", pkgID)
|
||||
}
|
||||
|
||||
// ── InstallPackage phases ────────────────────────
|
||||
|
||||
// receiveUpload reads the uploaded file into a temp file.
|
||||
// Returns the path, whether to clean up, and any error (already sent to client).
|
||||
func (h *PackageHandler) receiveUpload(c *gin.Context) (string, bool, error) {
|
||||
if regFile, ok := c.Get("_registry_file"); ok {
|
||||
return regFile.(string), false, nil
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return "", false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
||||
strings.HasSuffix(header.Filename, ".surface") ||
|
||||
strings.HasSuffix(header.Filename, ".zip")
|
||||
if !validExt {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
||||
return "", false, fmt.Errorf("invalid extension")
|
||||
}
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return "", false, fmt.Errorf("too large")
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return "", false, err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||
return "", false, err
|
||||
}
|
||||
tmpFile.Close()
|
||||
return tmpPath, true, nil
|
||||
}
|
||||
|
||||
// parseAndValidateArchive opens the zip, parses manifest.json, validates
|
||||
// starlark entry points, runs unicode security scans, and validates manifest structure.
|
||||
func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string) (*zip.ReadCloser, map[string]any, *ManifestInfo, error) {
|
||||
zr, err := zip.OpenReader(tmpPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
||||
return
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
// Find and parse manifest.json
|
||||
var manifest map[string]any
|
||||
@@ -289,19 +373,20 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
|
||||
return
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if manifest == nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
||||
return
|
||||
return nil, nil, nil, fmt.Errorf("missing manifest")
|
||||
}
|
||||
|
||||
// Scripts are loaded from disk at runtime — no _starlark_script injection.
|
||||
// Validate starlark entry point
|
||||
if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" {
|
||||
entryPoint := "script.star"
|
||||
if ep, ok := manifest["entry_point"].(string); ok && ep != "" {
|
||||
@@ -316,13 +401,13 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)})
|
||||
return
|
||||
return nil, nil, nil, fmt.Errorf("missing entry point")
|
||||
}
|
||||
}
|
||||
|
||||
// Unicode security gate — scan all scannable files for invisible/deceptive
|
||||
// characters before writing anything to the extension store.
|
||||
// Unicode security scan
|
||||
scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
@@ -344,116 +429,94 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
findings := sandbox.ScanSource(string(data), f.Name)
|
||||
if len(findings) > 0 {
|
||||
if blocked, reason := sandbox.Verdict(findings); blocked {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
||||
"error": "extension_blocked",
|
||||
"reason": fmt.Sprintf("%s (file: %s)", reason, f.Name),
|
||||
})
|
||||
return
|
||||
return nil, nil, nil, fmt.Errorf("blocked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate manifest structure (required fields, type, constraints)
|
||||
// Validate manifest structure
|
||||
mInfo, err := ValidateManifest(manifest)
|
||||
if err != nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
pkgID := mInfo.ID
|
||||
title := mInfo.Title
|
||||
pkgType := mInfo.Type
|
||||
|
||||
// Package signing hook (schema reserved — no crypto verification yet)
|
||||
// Signing hook (reserved)
|
||||
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
|
||||
if mInfo.Signature != "" {
|
||||
log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", pkgID, mInfo.Signature)
|
||||
log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", mInfo.ID, mInfo.Signature)
|
||||
} else {
|
||||
log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", pkgID)
|
||||
log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", mInfo.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicts with core packages
|
||||
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
if existing != nil && existing.Source == "core" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
|
||||
return zr, manifest, mInfo, nil
|
||||
}
|
||||
|
||||
// extractPackageAssets extracts js/, css/, assets/, star/ files to the packages directory.
|
||||
func (h *PackageHandler) extractPackageAssets(pkgID string, zr *zip.ReadCloser) {
|
||||
if h.packagesDir == "" {
|
||||
return
|
||||
}
|
||||
destDir := filepath.Join(h.packagesDir, pkgID)
|
||||
os.MkdirAll(destDir, 0755)
|
||||
|
||||
// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
|
||||
if h.packagesDir != "" {
|
||||
destDir := filepath.Join(h.packagesDir, pkgID)
|
||||
os.MkdirAll(destDir, 0755)
|
||||
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath := extractableRelPath(f.Name)
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, relPath)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
||||
continue
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
continue
|
||||
}
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
relPath := extractableRelPath(f.Name)
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
destPath := filepath.Join(destDir, relPath)
|
||||
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
||||
continue // path traversal
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
||||
}
|
||||
|
||||
// Use validated manifest fields for DB columns
|
||||
version := mInfo.Version
|
||||
description := mInfo.Description
|
||||
author := mInfo.Author
|
||||
tier := mInfo.Tier
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
pkgSource := "extension"
|
||||
if src, ok := c.Get("_registry_source"); ok {
|
||||
pkgSource = src.(string)
|
||||
}
|
||||
|
||||
// Register in database via Seed (upsert — handles re-install)
|
||||
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
|
||||
// registerPackage seeds the package in the database and updates metadata fields.
|
||||
// Returns the preserved enabled state and any error (already sent to client).
|
||||
func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *ManifestInfo, pkgSource, userID string, manifest map[string]any) (bool, error) {
|
||||
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, mInfo.Title, pkgSource, manifest); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
|
||||
return
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Read back to get the preserved enabled state (Seed doesn't override it)
|
||||
existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
preservedEnabled := true
|
||||
if existing != nil {
|
||||
preservedEnabled = existing.Enabled
|
||||
}
|
||||
|
||||
// Update fields that Seed doesn't set (type, version, author, etc.)
|
||||
pkg := &store.PackageRegistration{
|
||||
Title: title,
|
||||
Type: pkgType,
|
||||
Version: version,
|
||||
Description: description,
|
||||
Author: author,
|
||||
Tier: tier,
|
||||
Title: mInfo.Title,
|
||||
Type: mInfo.Type,
|
||||
Version: mInfo.Version,
|
||||
Description: mInfo.Description,
|
||||
Author: mInfo.Author,
|
||||
Tier: mInfo.Tier,
|
||||
IsSystem: false,
|
||||
Enabled: preservedEnabled,
|
||||
Manifest: manifest,
|
||||
@@ -464,17 +527,19 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
||||
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
||||
}
|
||||
return preservedEnabled, nil
|
||||
}
|
||||
|
||||
// applySchemaAndPermissions creates extension tables, syncs permissions/triggers,
|
||||
// and runs schema migrations.
|
||||
func (h *PackageHandler) applySchemaAndPermissions(c *gin.Context, pkgID string, mInfo *ManifestInfo, manifest map[string]any, existing *store.PackageRegistration) error {
|
||||
if tables, ok := ParseDBTables(manifest); ok {
|
||||
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil {
|
||||
log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// This creates the permission rows and sets status to pending_review
|
||||
// if the package declares permissions.
|
||||
SyncManifestPermissions(c, h.stores, pkgID, manifest)
|
||||
|
||||
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)
|
||||
|
||||
newSchemaVersion := ParseSchemaVersion(manifest)
|
||||
@@ -488,7 +553,7 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
|
||||
oldSchemaVersion, newSchemaVersion),
|
||||
})
|
||||
return
|
||||
return fmt.Errorf("downgrade")
|
||||
}
|
||||
if newSchemaVersion > oldSchemaVersion {
|
||||
if err := RunSchemaMigrations(
|
||||
@@ -500,89 +565,77 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "schema migration failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if pkgType == "workflow" {
|
||||
if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
|
||||
log.Printf("[packages] workflow install failed for %s: %v", pkgID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
// resolveDependencies validates and records library dependencies.
|
||||
func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manifest map[string]any) error {
|
||||
deps, ok := manifest["dependencies"].(map[string]any)
|
||||
if !ok || len(deps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Libraries must be installed first; consumers declare them.
|
||||
// If a dependency is missing but available as a bundled package, auto-install it.
|
||||
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
|
||||
// Clear stale dependency records from a previous install of the same consumer.
|
||||
if h.stores.Dependencies != nil {
|
||||
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
|
||||
}
|
||||
for libID, vSpec := range deps {
|
||||
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
if err != nil || lib == nil {
|
||||
// Attempt auto-install from bundled packages
|
||||
if h.bundledDir != "" {
|
||||
pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
|
||||
if _, statErr := os.Stat(pkgPath); statErr == nil {
|
||||
log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
|
||||
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Re-fetch after install
|
||||
lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
if h.stores.Dependencies != nil {
|
||||
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
|
||||
}
|
||||
|
||||
for libID, vSpec := range deps {
|
||||
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
if err != nil || lib == nil {
|
||||
// Attempt auto-install from bundled packages
|
||||
if h.bundledDir != "" {
|
||||
pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
|
||||
if _, statErr := os.Stat(pkgPath); statErr == nil {
|
||||
log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
|
||||
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
||||
})
|
||||
return installErr
|
||||
}
|
||||
}
|
||||
if lib == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
|
||||
return
|
||||
lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
}
|
||||
}
|
||||
if lib.Type != "library" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||
return
|
||||
}
|
||||
if lib.Status == "suspended" || lib.Status == "dormant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
||||
return
|
||||
}
|
||||
versionSpec, _ := vSpec.(string)
|
||||
if versionSpec == "" {
|
||||
versionSpec = ">=0.0.0"
|
||||
}
|
||||
if h.stores.Dependencies != nil {
|
||||
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
|
||||
ConsumerID: pkgID,
|
||||
LibraryID: libID,
|
||||
VersionSpec: versionSpec,
|
||||
ResolvedVer: lib.Version,
|
||||
}); err != nil {
|
||||
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
|
||||
return
|
||||
}
|
||||
if lib == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
|
||||
return fmt.Errorf("missing dep: %s", libID)
|
||||
}
|
||||
}
|
||||
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
||||
}
|
||||
|
||||
if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" {
|
||||
if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
|
||||
dflt := models.JSONMap{"id": pkgID}
|
||||
if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
|
||||
log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
|
||||
} else {
|
||||
log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
|
||||
if lib.Type != "library" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||
return fmt.Errorf("not a library: %s", libID)
|
||||
}
|
||||
if lib.Status == "suspended" || lib.Status == "dormant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
||||
return fmt.Errorf("dep %s is %s", libID, lib.Status)
|
||||
}
|
||||
versionSpec, _ := vSpec.(string)
|
||||
if versionSpec == "" {
|
||||
versionSpec = ">=0.0.0"
|
||||
}
|
||||
if h.stores.Dependencies != nil {
|
||||
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
|
||||
ConsumerID: pkgID,
|
||||
LibraryID: libID,
|
||||
VersionSpec: versionSpec,
|
||||
ResolvedVer: lib.Version,
|
||||
}); err != nil {
|
||||
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Known capabilities: (none yet — chat and legacy-sdk are future/removed).
|
||||
// checkCapabilities marks a package dormant if it declares unmet `requires` entries.
|
||||
func (h *PackageHandler) checkCapabilities(c *gin.Context, pkgID string, manifest map[string]any, preservedEnabled *bool) bool {
|
||||
knownCaps := map[string]bool{}
|
||||
var unmetReqs []string
|
||||
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
||||
@@ -593,27 +646,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
isDormant := len(unmetReqs) > 0
|
||||
if isDormant {
|
||||
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||
preservedEnabled = false
|
||||
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||
if len(unmetReqs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
resp := gin.H{
|
||||
"id": pkgID,
|
||||
"title": title,
|
||||
"type": pkgType,
|
||||
"version": version,
|
||||
"source": pkgSource,
|
||||
"enabled": preservedEnabled,
|
||||
}
|
||||
if isDormant {
|
||||
resp["status"] = "dormant"
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
h.broadcastPackageChanged("installed", pkgID)
|
||||
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||
*preservedEnabled = false
|
||||
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||
return true
|
||||
}
|
||||
|
||||
// extractableRelPath returns the relative path for a zip entry if it
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"armature/config"
|
||||
"armature/models"
|
||||
"armature/store"
|
||||
)
|
||||
|
||||
@@ -631,18 +632,20 @@ func (e *Engine) RenderLogin() gin.HandlerFunc {
|
||||
|
||||
// WorkflowPageData is passed to the workflow.html template via PageData.Data.
|
||||
type WorkflowPageData struct {
|
||||
ChannelID string
|
||||
ChannelTitle string
|
||||
ChannelDescription string
|
||||
SessionID string
|
||||
SessionName string
|
||||
StageMode string // custom | form_only | form_chat | review
|
||||
StageName string
|
||||
FormTemplateJSON string // typed form template JSON (empty if custom)
|
||||
TotalStages int
|
||||
CurrentStage int
|
||||
SurfacePkgID string
|
||||
BrandingJSON string
|
||||
EntryToken string
|
||||
WorkflowTitle string
|
||||
WorkflowDescription string
|
||||
SessionID string
|
||||
SessionName string
|
||||
StageMode string // form | review | delegated | automated
|
||||
StageName string
|
||||
FormTemplateJSON string // typed form template JSON (empty if delegated)
|
||||
TotalStages int
|
||||
CurrentStage int
|
||||
SurfacePkgID string
|
||||
BrandingJSON string
|
||||
InstanceID string // workflow instance ID (used for API calls)
|
||||
Status string // pending | active | completed | cancelled | stale
|
||||
}
|
||||
|
||||
// WorkflowLandingPageData is passed to workflow-landing.html.
|
||||
@@ -660,49 +663,106 @@ type WorkflowLandingPageData struct {
|
||||
PersonaName string
|
||||
PersonaIcon string
|
||||
StageCount int
|
||||
FirstStageMode string // custom | form_only | form_chat
|
||||
FirstStageMode string // form | review | delegated | automated
|
||||
ResumeURL string // non-empty if visitor has an active session
|
||||
}
|
||||
|
||||
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
|
||||
// RenderWorkflow renders the workflow execution page for a running instance.
|
||||
// Route: /w/:id — :id is either an instance ID or a public entry token.
|
||||
// The middleware (AuthOrSession) has already created/resumed the session.
|
||||
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// :id is the public entry token (from /w/:id)
|
||||
channelID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
routeID := c.Param("id")
|
||||
sessionID := c.GetString("session_id")
|
||||
|
||||
var title, description string
|
||||
if title == "" {
|
||||
title = "Workflow"
|
||||
}
|
||||
|
||||
// Load session display name
|
||||
sessionName := "Visitor"
|
||||
|
||||
// Load workflow stage info for form rendering
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
|
||||
var totalStages, currentStage int
|
||||
stageMode = "custom" // default
|
||||
|
||||
instanceName, _, _ := e.loadBranding()
|
||||
|
||||
// Try to load instance — the route param may be an instance ID
|
||||
// or an entry token. Also check the ?token= query param.
|
||||
var inst *models.WorkflowInstance
|
||||
if e.stores.Workflows != nil {
|
||||
// First try: query param token (from landing page redirect)
|
||||
if qToken := c.Query("token"); qToken != "" {
|
||||
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, qToken)
|
||||
}
|
||||
// Second try: route param as entry token
|
||||
if inst == nil {
|
||||
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, routeID)
|
||||
}
|
||||
// Third try: route param as instance ID
|
||||
if inst == nil {
|
||||
inst, _ = e.stores.Workflows.GetInstance(ctx, routeID)
|
||||
}
|
||||
}
|
||||
|
||||
if inst == nil {
|
||||
c.String(http.StatusNotFound, "Workflow instance not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Load workflow definition for title, description, branding
|
||||
title, description := "Workflow", ""
|
||||
var brandingJSON string
|
||||
wf, _ := e.stores.Workflows.GetByID(ctx, inst.WorkflowID)
|
||||
if wf != nil {
|
||||
title = wf.Name
|
||||
description = wf.Description
|
||||
if len(wf.Branding) > 0 {
|
||||
brandingJSON = string(wf.Branding)
|
||||
}
|
||||
}
|
||||
|
||||
// Load stages to resolve current stage info
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID string
|
||||
var totalStages, currentStage int
|
||||
stageMode = "form" // default
|
||||
|
||||
stages, _ := e.stores.Workflows.ListStages(ctx, inst.WorkflowID)
|
||||
totalStages = len(stages)
|
||||
for i, s := range stages {
|
||||
if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage {
|
||||
currentStage = i
|
||||
stageName = s.Name
|
||||
stageMode = s.StageMode
|
||||
if stageMode == "" {
|
||||
stageMode = "form"
|
||||
}
|
||||
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "null" {
|
||||
formTplJSON = string(s.FormTemplate)
|
||||
}
|
||||
if s.SurfacePkgID != nil {
|
||||
surfacePkgID = *s.SurfacePkgID
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the actual entry token from the instance (used by JS API calls)
|
||||
entryToken := ""
|
||||
if inst.EntryToken != nil {
|
||||
entryToken = *inst.EntryToken
|
||||
}
|
||||
|
||||
e.Render(c, "workflow.html", PageData{
|
||||
Surface: "workflow",
|
||||
InstanceName: instanceName,
|
||||
Data: WorkflowPageData{
|
||||
ChannelID: channelID,
|
||||
ChannelTitle: title,
|
||||
ChannelDescription: description,
|
||||
SessionID: sessionID,
|
||||
SessionName: sessionName,
|
||||
StageMode: stageMode,
|
||||
StageName: stageName,
|
||||
FormTemplateJSON: formTplJSON,
|
||||
TotalStages: totalStages,
|
||||
CurrentStage: currentStage,
|
||||
SurfacePkgID: surfacePkgID,
|
||||
BrandingJSON: brandingJSON,
|
||||
EntryToken: entryToken,
|
||||
WorkflowTitle: title,
|
||||
WorkflowDescription: description,
|
||||
SessionID: sessionID,
|
||||
SessionName: sessionName,
|
||||
StageMode: stageMode,
|
||||
StageName: stageName,
|
||||
FormTemplateJSON: formTplJSON,
|
||||
TotalStages: totalStages,
|
||||
CurrentStage: currentStage,
|
||||
SurfacePkgID: surfacePkgID,
|
||||
BrandingJSON: brandingJSON,
|
||||
InstanceID: inst.ID,
|
||||
Status: inst.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,19 +146,15 @@
|
||||
<p class="wf-description">{{.Data.Description}}</p>
|
||||
{{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-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div>
|
||||
{{if eq .Data.FirstStageMode "form_chat"}}
|
||||
<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}}
|
||||
<span>You'll be working with <strong>{{.Data.PersonaName}}</strong></span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<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>
|
||||
|
||||
{{if .Data.ResumeURL}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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/x-icon" href="{{.BasePath}}/favicon.ico?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 p { color: var(--text-2); }
|
||||
|
||||
/* ── Chat ───────────────────────── */
|
||||
.wf-chat {
|
||||
flex: 1; overflow-y: auto; padding: 16px 24px;
|
||||
}
|
||||
.wf-chat .message {
|
||||
margin-bottom: 12px; padding: 10px 14px;
|
||||
border-radius: 8px; max-width: 80%;
|
||||
}
|
||||
.wf-chat .message.user { background: var(--accent-dim); margin-left: auto; }
|
||||
.wf-chat .message.assistant { background: var(--bg-raised); }
|
||||
.wf-chat .message .meta { font-size: 11px; color: var(--text-3); margin-bottom: 4px; }
|
||||
.wf-chat .message .content { font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
|
||||
|
||||
.wf-input {
|
||||
padding: 12px 24px; border-top: 1px solid var(--border);
|
||||
background: var(--bg-surface); display: flex; gap: 8px;
|
||||
}
|
||||
.wf-input textarea {
|
||||
flex: 1; resize: none; background: var(--input-bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px;
|
||||
font-family: var(--font); font-size: 14px; min-height: 42px; max-height: 160px;
|
||||
}
|
||||
.wf-input textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.wf-input button {
|
||||
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
||||
padding: 0 20px; font-weight: 600; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.wf-input button:hover { background: var(--accent-hover); }
|
||||
.wf-input button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.wf-session-info {
|
||||
padding: 8px 24px; font-size: 12px; color: var(--text-3);
|
||||
border-top: 1px solid var(--border); background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Form+Chat layout ───────────── */
|
||||
.wf-body-split { display: flex; flex: 1; overflow: hidden; }
|
||||
.wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; }
|
||||
.wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; }
|
||||
.wf-body-split .wf-chat { flex: 1; }
|
||||
/* ── Unsupported mode fallback ─── */
|
||||
.wf-unsupported {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; text-align: center; color: var(--text-2);
|
||||
}
|
||||
|
||||
/* ── Branding (v0.35.0) ────────── */
|
||||
/* ── Branding ────────────────────── */
|
||||
.wf-branding-logo { max-height: 32px; margin-bottom: 4px; }
|
||||
.wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; }
|
||||
|
||||
/* ── Progressive Forms (v0.35.0) ── */
|
||||
/* ── Progressive Forms ────────── */
|
||||
.wf-fieldset-nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 16px; padding-bottom: 12px;
|
||||
@@ -175,8 +145,8 @@
|
||||
<body>
|
||||
<div class="wf-shell">
|
||||
<div class="wf-header" id="wfHeader">
|
||||
<h1>{{.Data.ChannelTitle}}</h1>
|
||||
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
|
||||
<h1>{{.Data.WorkflowTitle}}</h1>
|
||||
{{if .Data.WorkflowDescription}}<p>{{.Data.WorkflowDescription}}</p>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Data.StageName}}
|
||||
@@ -188,62 +158,55 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Stage surface mount point (v0.30.2) -->
|
||||
<!-- Modes: form_only, form_chat, chat_only, review, or custom surface via SurfacePkgID -->
|
||||
<!-- Stage surface: form, review, delegated (custom surface), or automated -->
|
||||
|
||||
{{if .Data.SurfacePkgID}}
|
||||
<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>
|
||||
{{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"}}
|
||||
<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="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></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}}
|
||||
<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 class="wf-unsupported">
|
||||
<p>This stage type ({{.Data.StageMode}}) does not have an interactive surface.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<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>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const BASE = '{{.BasePath}}';
|
||||
const CHAN_ID = '{{.Data.ChannelID}}';
|
||||
const INSTANCE_ID = '{{.Data.InstanceID}}';
|
||||
const ENTRY_TOKEN = '{{.Data.EntryToken}}';
|
||||
const SESSION_ID = '{{.Data.SessionID}}';
|
||||
const STAGE_MODE = '{{.Data.StageMode}}';
|
||||
const TOTAL_STAGES = {{.Data.TotalStages}};
|
||||
const CURRENT_STAGE = {{.Data.CurrentStage}};
|
||||
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;
|
||||
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Branding
|
||||
var BRANDING = null;
|
||||
try { BRANDING = JSON.parse('{{.Data.BrandingJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Apply branding
|
||||
// Apply branding
|
||||
(function() {
|
||||
if (!BRANDING) return;
|
||||
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 _fieldsetCount = 0;
|
||||
|
||||
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL) {
|
||||
if (STAGE_MODE === 'form' && FORM_TPL) {
|
||||
var formArea = document.getElementById('formArea');
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
renderProgressiveForm(formArea, FORM_TPL);
|
||||
@@ -326,7 +289,6 @@
|
||||
html += '</div></div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
// Apply conditional fields for all fieldsets
|
||||
for (var s = 0; s < tpl.fieldsets.length; s++) {
|
||||
applyConditionalFields(tpl.fieldsets[s].fields);
|
||||
}
|
||||
@@ -350,7 +312,7 @@
|
||||
var handler = function() { evaluateCondition(field); };
|
||||
srcEl.addEventListener('change', handler);
|
||||
srcEl.addEventListener('input', handler);
|
||||
handler(); // initial evaluation
|
||||
handler();
|
||||
})(f);
|
||||
}
|
||||
}
|
||||
@@ -436,7 +398,7 @@
|
||||
}
|
||||
|
||||
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');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
@@ -448,7 +410,7 @@
|
||||
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 || [];
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
allFields = [];
|
||||
@@ -461,7 +423,7 @@
|
||||
var f = allFields[i];
|
||||
var el = document.getElementById('ff_' + f.key);
|
||||
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 + '"]');
|
||||
if (fieldWrap && fieldWrap.classList.contains('cond-hidden')) continue;
|
||||
if (f.type === 'checkbox') {
|
||||
@@ -474,7 +436,7 @@
|
||||
}
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: data }),
|
||||
@@ -490,16 +452,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Success
|
||||
if (result.status === 'advanced' || result.status === 'completed') {
|
||||
showFormSuccess(result.status === 'completed'
|
||||
? 'Thank you! Your submission is complete.'
|
||||
: 'Submitted! Moving to the next step...');
|
||||
if (result.status === 'advanced') {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
// Success — API returns instance status: "active" (advanced) or "completed"
|
||||
if (result.status === 'completed') {
|
||||
showFormSuccess('Thank you! Your submission is complete.');
|
||||
} 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) {
|
||||
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) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
@@ -550,46 +493,7 @@
|
||||
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
window.sendMessage = async function() {
|
||||
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) ──────────
|
||||
// ── Custom surface (delegated mode) ──────────
|
||||
if (SURFACE_PKG_ID) {
|
||||
var mount = document.getElementById('customSurfaceMount');
|
||||
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') {
|
||||
var dataPanel = document.getElementById('reviewDataPanel');
|
||||
var actionPanel = document.getElementById('reviewActionPanel');
|
||||
@@ -624,7 +528,7 @@
|
||||
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
|
||||
|
||||
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' },
|
||||
});
|
||||
if (resp.ok) {
|
||||
@@ -666,7 +570,7 @@
|
||||
actHtml += '</div>';
|
||||
actionPanel.innerHTML = actHtml;
|
||||
|
||||
// Keyboard shortcuts (v0.35.0)
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -681,7 +585,7 @@
|
||||
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
|
||||
this.disabled = true;
|
||||
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' },
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
@@ -702,7 +606,7 @@
|
||||
if (!reason) return;
|
||||
this.disabled = true;
|
||||
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' },
|
||||
body: JSON.stringify({ data: { _action: 'reject', reason: reason } }),
|
||||
});
|
||||
|
||||
@@ -1777,7 +1777,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
enum: [form, review, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -1817,7 +1817,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
enum: [form, review, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -3209,7 +3209,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
enum: [form, review, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
@@ -3250,7 +3250,7 @@ paths:
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
enum: [form, review, delegated, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
|
||||
629
server/store/sqlite/workflows_test.go
Normal file
629
server/store/sqlite/workflows_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user