Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a681930b1 | |||
| 852b86dded | |||
| 58e7335923 | |||
| 15daea8cc1 | |||
| f31c45db07 | |||
| 217e380ff4 | |||
| f444dce133 | |||
| 0b76491d7d | |||
| 3a75cc9136 | |||
| ea10718125 | |||
| 3bee04f331 | |||
| 44b13fe6f1 | |||
| 696a660b40 |
@@ -0,0 +1,220 @@
|
||||
name: "retrospect-workflow"
|
||||
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
|
||||
roles:
|
||||
analyst:
|
||||
description: "Scans thread execution for anomalies and produces a findings report"
|
||||
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
|
||||
capabilities:
|
||||
- data-analysis
|
||||
procedure: |
|
||||
You receive a completed thread ID in your task prompt.
|
||||
|
||||
Phase 0 — Validation (must pass before any analysis):
|
||||
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
|
||||
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
|
||||
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
|
||||
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
|
||||
4. Compare the thread's workflow hash against the current registered version:
|
||||
- Run `uwf workflow show <name>` to get the current hash
|
||||
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
|
||||
|
||||
Phase 1 — Overview scan:
|
||||
5. From the step list, compute a health signal for each step:
|
||||
- Duration: flag if >2x the median of other steps
|
||||
- Output tokens: flag if >2x the median
|
||||
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
|
||||
- Step count: flag if the same role appears more than expected (indicates loops)
|
||||
6. If no anomalies found AND versions match: output $status=clean
|
||||
7. If no anomalies found BUT versions differ:
|
||||
- Diff the two workflow versions to check if any procedure changes are relevant
|
||||
- If the current version already addresses potential concerns: output $status=clean with a note
|
||||
- Otherwise: proceed to Phase 2
|
||||
|
||||
Phase 2 — Targeted deep-dive (only for flagged steps):
|
||||
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
|
||||
9. Analyze the turn sequence for:
|
||||
- Repeated tool calls with the same or similar input (blind retries)
|
||||
- Tool errors followed by no strategy change (same approach retried)
|
||||
- Unnecessary exploration (reading files or running commands unrelated to the task)
|
||||
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
|
||||
- Excessive turns before reaching the goal
|
||||
10. For each finding, record:
|
||||
- Which role and step hash
|
||||
- What happened (specific turn indices and commands)
|
||||
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
|
||||
- Suggested fix (what to add/change in the procedure)
|
||||
11. If versions differ: compare findings against the version diff.
|
||||
Mark any finding that is already fixed in the current version as "resolved_in_current".
|
||||
Only report findings that are NOT yet addressed.
|
||||
|
||||
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
|
||||
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "clean" }
|
||||
summary: { type: string }
|
||||
required: [$status, summary]
|
||||
- properties:
|
||||
$status: { const: "findings" }
|
||||
report: { type: string }
|
||||
targetWorkflow: { type: string }
|
||||
required: [$status, report, targetWorkflow]
|
||||
- properties:
|
||||
$status: { const: "wrong_project" }
|
||||
workflowName: { type: string }
|
||||
required: [$status, workflowName]
|
||||
proposer:
|
||||
description: "Translates findings into concrete workflow edits"
|
||||
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
|
||||
capabilities:
|
||||
- planning
|
||||
procedure: |
|
||||
1. Read the analyst's findings report from your task prompt
|
||||
2. Locate the target workflow YAML:
|
||||
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
|
||||
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
|
||||
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
|
||||
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
|
||||
3. Read the current workflow YAML to understand existing procedures
|
||||
4. For each finding, draft a minimal edit:
|
||||
- Prefer adding a pitfall note or clarifying instruction over restructuring
|
||||
- If a procedure step is ambiguous, make it explicit
|
||||
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
|
||||
- Keep edits surgical — don't rewrite procedures that work fine
|
||||
5. Check if existing tests need updating (search for test files referencing the workflow)
|
||||
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
|
||||
|
||||
The plan should list each edit with:
|
||||
- File path
|
||||
- What to change (old text → new text, or addition)
|
||||
- Why (linked to which finding)
|
||||
- Any test updates needed
|
||||
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "no_action" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
developer:
|
||||
description: "Applies the proposed workflow edits"
|
||||
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
|
||||
4. `cd .worktrees/retrospect/<short-slug> && bun install`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then apply changes:
|
||||
6. Read the change plan from CAS: `uwf cas get <plan hash>`
|
||||
7. Apply each edit from the plan to the workflow YAML
|
||||
8. Update or add tests as specified in the plan
|
||||
9. Run `bun run build` and `bun test` to verify
|
||||
10. Run `bun run check` for lint
|
||||
11. Commit with message: `improve: <workflow-name> — <brief summary>`
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Reviews the workflow edits for correctness"
|
||||
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
|
||||
capabilities:
|
||||
- code-review
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Review criteria:
|
||||
1. Each edit must trace back to a specific finding — no drive-by changes
|
||||
2. Edits should be minimal — don't rewrite working procedures
|
||||
3. New pitfall notes or instructions must be clear and actionable
|
||||
4. Tests must be updated if assertions changed
|
||||
5. `bun run build` and `bun test` must pass
|
||||
6. `bunx biome check` must pass
|
||||
|
||||
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
|
||||
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message: `git commit -m "improve: <workflow> — <summary>"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
|
||||
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
|
||||
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
|
||||
- PR description must include: What / Why / Findings / Changes sections
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
|
||||
analyst:
|
||||
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
|
||||
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
|
||||
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
|
||||
proposer:
|
||||
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
|
||||
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
|
||||
approved: { role: "committer", prompt: "Approved. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow improved." }
|
||||
+261
-140
@@ -1,202 +1,323 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution adapted for the workflow monorepo with bun + vitest"
|
||||
name: solve-issue
|
||||
description: TDD-driven issue resolution adapted for the workflow monorepo with bun + vitest
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
description: Analyzes issue and outputs a TDD test spec
|
||||
goal: You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify.
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: 'On first run (no previous steps):
|
||||
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md) in the repo
|
||||
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
|
||||
1. Read the tester''s output from the previous step to understand what''s wrong with the spec
|
||||
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
|
||||
After producing the test spec:
|
||||
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
|
||||
|
||||
|
||||
IMPORTANT: Extract the repo remote (owner/repo) from git:
|
||||
|
||||
```bash
|
||||
|
||||
git remote get-url origin | sed ''s|.*[:/]\([^/]*/[^.]*\).*|\1|''
|
||||
|
||||
```
|
||||
|
||||
Store the result as repoRemote in your frontmatter output so downstream roles can use it for tea/API calls.'
|
||||
output: Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info.
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
required: [$status]
|
||||
- properties:
|
||||
$status:
|
||||
const: ready
|
||||
plan:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- plan
|
||||
- repoPath
|
||||
- properties:
|
||||
$status:
|
||||
const: insufficient_info
|
||||
required:
|
||||
- $status
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
description: TDD implementation per test spec
|
||||
goal: You are a developer agent. You implement code changes following TDD — write tests first, then implementation.
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If bounced back from reviewer or tester (branch already exists):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec (use vitest)
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
- coding
|
||||
procedure: "IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.\nThe repo path and other details are provided in your task prompt.\n\nBefore starting any work,\
|
||||
\ set up an isolated worktree:\n1. cd into the repo path provided in your task prompt\n2. `git fetch origin` to get latest refs\n3. First time (no existing branch):\n - `git worktree add .worktrees/fix/<issue-number>-<short-slug>\
|
||||
\ -b fix/<issue-number>-<short-slug> origin/main`\n - `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`\n4. If bounced back from reviewer or tester (branch already exists):\n - cd\
|
||||
\ into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`\n - `git fetch origin && git rebase origin/main`\n5. ALL subsequent work must happen inside the worktree directory.\n\
|
||||
\nThen implement TDD:\n6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)\n7. If bounced back from reviewer or tester: read the\
|
||||
\ previous role's feedback in your task prompt\n8. Write tests first based on the spec (use vitest)\n9. Implement the code to make tests pass\n10. Ensure `bun run build` passes with no errors\n11.\
|
||||
\ Run `bun test` to verify all tests pass\n\nIf you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,\nor repeated attempts fail), set $status=failed\
|
||||
\ with a reason.\n"
|
||||
output: List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
branch:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- branch
|
||||
- worktree
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- reason
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
description: Code standards compliance check
|
||||
goal: You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job).
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: 'The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
2. If the branch doesn''t correspond to the issue, flag it in your output and reject
|
||||
|
||||
|
||||
Then perform code review:
|
||||
|
||||
Hard checks (must all pass):
|
||||
|
||||
3. `bun run build` — no build errors
|
||||
|
||||
4. `bunx biome check` — no lint violations
|
||||
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
|
||||
Soft checks (review against project conventions from CLAUDE.md):
|
||||
|
||||
- Functional-first: functions + types, no classes (except for errors or third-party requirements)
|
||||
|
||||
- Named exports only, no default exports
|
||||
|
||||
- No optional properties (use `T | null` instead of `?:`)
|
||||
|
||||
- Folder module discipline: index.ts only re-exports, types in types.ts
|
||||
|
||||
- Crockford Base32 log tags (8-char, unique per call site)
|
||||
|
||||
- No `console.log` in production code (use createLogger from @uncaged/workflow-util)
|
||||
|
||||
- No dynamic imports in production code
|
||||
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
|
||||
'
|
||||
output: Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
- properties:
|
||||
$status:
|
||||
const: approved
|
||||
branch:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- branch
|
||||
- worktree
|
||||
- properties:
|
||||
$status:
|
||||
const: rejected
|
||||
comments:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- comments
|
||||
- worktree
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
description: Functional correctness verification
|
||||
goal: You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec.
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
- testing
|
||||
procedure: "The worktree path is provided in your task prompt. cd into it first.\n\n1. Run `bun test` for automated test verification\n2. Read the test spec from CAS: `uwf cas get <plan hash>` (find\
|
||||
\ the hash from the planner step in the thread history)\n3. Verify each scenario in the spec is covered and passing\n4. Determine outcome:\n - passed: all scenarios verified, tests pass\n - fix_code:\
|
||||
\ tests fail or implementation doesn't match spec → send back to developer\n - fix_spec: the spec itself is wrong or incomplete → send back to planner\n"
|
||||
output: Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status:
|
||||
const: passed
|
||||
branch:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- branch
|
||||
- worktree
|
||||
- properties:
|
||||
$status:
|
||||
const: fix_code
|
||||
report:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- report
|
||||
- properties:
|
||||
$status:
|
||||
const: fix_spec
|
||||
report:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- report
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
description: Commits and creates PR
|
||||
goal: You are a committer agent. You create a clean commit and push a PR linking the original issue.
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
|
||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
procedure: "The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.\ncd into the worktree first.\n\nNote: You inherit the developer's worktree and branch. Do NOT\
|
||||
\ create a new branch.\n1. Stage all changes: `git add -A`\n2. Commit with a descriptive message referencing the issue: `git commit -m \"type: description\\n\\nFixes #N\"`\n3. Push the branch: `git\
|
||||
\ push -u origin <branch-name>`\n4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.\n - If no output or push failed: capture the error, mark hook_failed\n\
|
||||
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):\n ```bash\n GITEA_TOKEN=$(cfg get GITEA_TOKEN)\n curl -s -X POST -H \"Authorization: token $GITEA_TOKEN\" -H \"Content-Type: application/json\" \\\n\
|
||||
\ \"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls\" \\\n -d '{\"title\":\"...\",\"body\":\"...\",\"head\":\"<branch>\",\"base\":\"main\"}'\n ```\n - The repo remote (owner/repo format, e.g. \"uncaged/workflow\") is given in your task prompt — use it directly.\n\
|
||||
\ - PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref\n6. **Verify PR was created** — parse the curl response JSON: it must contain a `\"number\"` field. Print the PR URL.\n\
|
||||
\ - If curl returns an error or no number field: capture the response, mark hook_failed\n7. After PR creation, clean up the worktree:\n - cd to the repo root (parent of .worktrees)\n - `git worktree remove <worktree-path>`"
|
||||
output: Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
- properties:
|
||||
$status:
|
||||
const: committed
|
||||
prUrl:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- prUrl
|
||||
- properties:
|
||||
$status:
|
||||
const: hook_failed
|
||||
error:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- error
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
_:
|
||||
role: planner
|
||||
prompt: Analyze the issue and produce an implementation plan.
|
||||
planner:
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
insufficient_info:
|
||||
role: $END
|
||||
prompt: Insufficient information to proceed; end the workflow.
|
||||
ready:
|
||||
role: developer
|
||||
prompt: 'Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}.'
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
done:
|
||||
role: reviewer
|
||||
prompt: 'Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}.'
|
||||
failed:
|
||||
role: $END
|
||||
prompt: 'Developer failed: {{{reason}}}. Ending workflow.'
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||
rejected:
|
||||
role: developer
|
||||
prompt: 'Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}. Repo remote: {{{repoRemote}}}.'
|
||||
approved:
|
||||
role: tester
|
||||
prompt: 'Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}. Repo remote: {{{repoRemote}}}.'
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
fix_code:
|
||||
role: developer
|
||||
prompt: 'Tests found code issues: {{{report}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}.'
|
||||
fix_spec:
|
||||
role: planner
|
||||
prompt: 'Tests found spec issues: {{{report}}}. Revise the test spec. Repo remote: {{{repoRemote}}}.'
|
||||
passed:
|
||||
role: committer
|
||||
prompt: 'All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}. Repo remote (owner/repo): {{{repoRemote}}}.'
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
hook_failed:
|
||||
role: developer
|
||||
prompt: 'Push hook failed: {{{error}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}.'
|
||||
committed:
|
||||
role: $END
|
||||
prompt: 'PR created: {{{prUrl}}}. Workflow complete.'
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
"name": "@uncaged/cli-dashboard",
|
||||
"version": "1.0.0",
|
||||
"bin": {
|
||||
"urec": "./src/urec.mjs",
|
||||
"uconn": "./src/uconn.mjs",
|
||||
"urec": "./dist/urec.js",
|
||||
"uconn": "./dist/uconn.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/dashboard-server": "workspace:^",
|
||||
@@ -53,6 +53,10 @@
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
@@ -282,14 +286,32 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@uncaged/cli-dashboard": ["@uncaged/cli-dashboard@workspace:packages/cli"],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "worker-dashboard",
|
||||
"private": true,
|
||||
"description": "Uncaged Dashboard - a real-time distributed command execution monitoring system",
|
||||
"workspaces": ["packages/*"],
|
||||
"scripts": {
|
||||
"dev:server": "node packages/server/src/index.mjs",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# @uncaged/cli-dashboard
|
||||
|
||||
CLI tools for UWF Worker Dashboard.
|
||||
CLI tools for Uncaged Dashboard.
|
||||
|
||||
## Commands
|
||||
|
||||
### `urec <command> [args...]`
|
||||
Run a command and record its output to `~/.uwf-dashboard/records/`.
|
||||
Run a command and record its output to `~/.uncaged/dashboard/records/`.
|
||||
|
||||
### `uconn [--url <ws-url>]`
|
||||
Connect to the dashboard server and sync records. Defaults to `wss://dashboard.shazhou.work/ws/worker`.
|
||||
|
||||
## Migration
|
||||
|
||||
If you have existing data in `~/.uwf-dashboard`, it will be automatically migrated to `~/.uncaged/dashboard` on first run.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("CLI Package Metadata", () => {
|
||||
it("should have 'Uncaged Dashboard' in package description", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
||||
if (pkg.description) {
|
||||
expect(pkg.description.toLowerCase()).toContain("uncaged");
|
||||
expect(pkg.description.toLowerCase()).not.toContain("uwf");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Help Text", () => {
|
||||
it("urec.ts should not contain 'UWF Dashboard' references", () => {
|
||||
const content = readFileSync(join(__dirname, "..", "src", "urec.ts"), "utf-8");
|
||||
expect(content.toLowerCase()).not.toContain("uwf dashboard");
|
||||
});
|
||||
|
||||
it("uconn.ts should not contain 'UWF Dashboard' references", () => {
|
||||
const content = readFileSync(join(__dirname, "..", "src", "uconn.ts"), "utf-8");
|
||||
expect(content.toLowerCase()).not.toContain("uwf dashboard");
|
||||
});
|
||||
|
||||
it("CLI README should reference 'Uncaged' not 'UWF' in user-facing text", () => {
|
||||
const readmePath = join(__dirname, "..", "README.md");
|
||||
if (existsSync(readmePath)) {
|
||||
const content = readFileSync(readmePath, "utf-8");
|
||||
expect(content).toContain("Uncaged");
|
||||
expect(content).toContain(".uncaged/dashboard");
|
||||
expect(content).not.toContain("UWF Worker Dashboard");
|
||||
expect(content).not.toContain("UWF Dashboard");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Directory Migration", () => {
|
||||
it("urec.ts should use new directory path as primary", () => {
|
||||
const content = readFileSync(join(__dirname, "..", "src", "urec.ts"), "utf-8");
|
||||
expect(content).toContain('".uncaged/dashboard"');
|
||||
expect(content).toContain(', "records")');
|
||||
// Should define NEW_BASE_DIR before RECORDS_DIR
|
||||
const newBaseIndex = content.indexOf("NEW_BASE_DIR");
|
||||
const recordsDirIndex = content.indexOf("RECORDS_DIR: string = join(NEW_BASE_DIR");
|
||||
expect(newBaseIndex).toBeGreaterThan(0);
|
||||
expect(recordsDirIndex).toBeGreaterThan(newBaseIndex);
|
||||
});
|
||||
|
||||
it("uconn.ts should use new directory paths as primary", () => {
|
||||
const content = readFileSync(join(__dirname, "..", "src", "uconn.ts"), "utf-8");
|
||||
expect(content).toContain('".uncaged/dashboard"');
|
||||
expect(content).toContain(', "records")');
|
||||
expect(content).toContain(', ".synced")');
|
||||
// Should define NEW_BASE_DIR and use it for RECORDS_DIR and SYNCED_FILE
|
||||
const newBaseIndex = content.indexOf("NEW_BASE_DIR");
|
||||
const recordsDirIndex = content.indexOf("RECORDS_DIR: string = join(NEW_BASE_DIR");
|
||||
const syncedFileIndex = content.indexOf("SYNCED_FILE: string = join(NEW_BASE_DIR");
|
||||
expect(newBaseIndex).toBeGreaterThan(0);
|
||||
expect(recordsDirIndex).toBeGreaterThan(newBaseIndex);
|
||||
expect(syncedFileIndex).toBeGreaterThan(newBaseIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy Directory Auto-Migration", () => {
|
||||
let testHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testHome = join(tmpdir(), `test-migration-${Date.now()}`);
|
||||
mkdirSync(testHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testHome)) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should migrate from legacy .uwf-dashboard to .uncaged/dashboard", async () => {
|
||||
// Setup: Create legacy directory with test data
|
||||
const legacyDir = join(testHome, ".uwf-dashboard", "records");
|
||||
const newDir = join(testHome, ".uncaged", "dashboard", "records");
|
||||
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
const testRecord = { id: "test-123", device: "test-device", command: "echo test" };
|
||||
writeFileSync(join(legacyDir, "test-123.json"), JSON.stringify(testRecord));
|
||||
|
||||
// Test migration logic
|
||||
expect(existsSync(legacyDir)).toBe(true);
|
||||
expect(existsSync(newDir)).toBe(false);
|
||||
|
||||
// Migration should happen when new directory doesn't exist
|
||||
// This test verifies the paths are correct
|
||||
});
|
||||
|
||||
it("should handle empty legacy directory", () => {
|
||||
const legacyDir = join(testHome, ".uwf-dashboard");
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
|
||||
expect(existsSync(legacyDir)).toBe(true);
|
||||
// Migration should create new directory even if old is empty
|
||||
});
|
||||
|
||||
it("should not migrate when new directory already exists", () => {
|
||||
const newDir = join(testHome, ".uncaged", "dashboard", "records");
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
|
||||
const existingRecord = { id: "existing", device: "test" };
|
||||
writeFileSync(join(newDir, "existing.json"), JSON.stringify(existingRecord));
|
||||
|
||||
expect(existsSync(newDir)).toBe(true);
|
||||
const content = readFileSync(join(newDir, "existing.json"), "utf-8");
|
||||
expect(JSON.parse(content).id).toBe("existing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Package Metadata", () => {
|
||||
it("frontend package.json should reference Uncaged", () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "..", "frontend", "package.json"), "utf-8"),
|
||||
);
|
||||
if (pkg.description) {
|
||||
expect(pkg.description.toLowerCase()).toContain("uncaged");
|
||||
expect(pkg.description.toLowerCase()).not.toContain("uwf");
|
||||
}
|
||||
});
|
||||
|
||||
it("server package.json should reference Uncaged", () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "..", "server", "package.json"), "utf-8"),
|
||||
);
|
||||
if (pkg.description) {
|
||||
expect(pkg.description.toLowerCase()).toContain("uncaged");
|
||||
expect(pkg.description.toLowerCase()).not.toContain("uwf");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const RECORDS_DIR = join(homedir(), ".uwf-dashboard/records");
|
||||
const RECORDS_DIR = join(homedir(), ".uncaged/dashboard/records");
|
||||
const UREC_PATH = join(import.meta.dirname, "../dist/urec.js");
|
||||
|
||||
describe("urec Type Safety Tests", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@uncaged/cli-dashboard",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Uncaged Dashboard CLI - command recording and sync tools (urec and uconn)",
|
||||
"bin": {
|
||||
"urec": "./dist/urec.js",
|
||||
"uconn": "./dist/uconn.js"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { homedir, hostname } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { MSG } from "@uncaged/dashboard-server/protocol";
|
||||
@@ -25,10 +26,53 @@ program.option("--url <url>", "WebSocket URL", "wss://dashboard.shazhou.work/ws/
|
||||
const opts = program.opts<{ url: string }>();
|
||||
const WS_URL: string = opts.url;
|
||||
const DEVICE: string = hostname();
|
||||
const RECORDS_DIR: string = join(homedir(), ".uwf-dashboard/records");
|
||||
const SYNCED_FILE: string = join(homedir(), ".uwf-dashboard/.synced");
|
||||
|
||||
// Migration: Move from legacy .uwf-dashboard to .uncaged/dashboard
|
||||
const LEGACY_DIR: string = join(homedir(), ".uwf-dashboard");
|
||||
const NEW_BASE_DIR: string = join(homedir(), ".uncaged/dashboard");
|
||||
const RECORDS_DIR: string = join(NEW_BASE_DIR, "records");
|
||||
const SYNCED_FILE: string = join(NEW_BASE_DIR, ".synced");
|
||||
const THREE_DAYS: number = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function migrateFromLegacy(): Promise<void> {
|
||||
if (existsSync(LEGACY_DIR) && !existsSync(NEW_BASE_DIR)) {
|
||||
console.log("Migrating from legacy .uwf-dashboard to .uncaged/dashboard...");
|
||||
await mkdir(NEW_BASE_DIR, { recursive: true });
|
||||
|
||||
// Migrate records directory if it exists
|
||||
const legacyRecordsDir = join(LEGACY_DIR, "records");
|
||||
if (existsSync(legacyRecordsDir)) {
|
||||
const files = await readdir(legacyRecordsDir);
|
||||
await mkdir(RECORDS_DIR, { recursive: true });
|
||||
|
||||
for (const file of files) {
|
||||
const oldPath = join(legacyRecordsDir, file);
|
||||
const newPath = join(RECORDS_DIR, file);
|
||||
await rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
await rmdir(legacyRecordsDir);
|
||||
}
|
||||
|
||||
// Migrate .synced file if it exists
|
||||
const legacySyncedFile = join(LEGACY_DIR, ".synced");
|
||||
if (existsSync(legacySyncedFile)) {
|
||||
await rename(legacySyncedFile, SYNCED_FILE);
|
||||
}
|
||||
|
||||
// Remove legacy directory if empty
|
||||
try {
|
||||
const remaining = await readdir(LEGACY_DIR);
|
||||
if (remaining.length === 0) {
|
||||
await rmdir(LEGACY_DIR);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log("Migration complete.");
|
||||
}
|
||||
}
|
||||
|
||||
await migrateFromLegacy();
|
||||
await mkdir(RECORDS_DIR, { recursive: true });
|
||||
|
||||
let synced: Set<string> = new Set();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readdir, rename, rmdir, writeFile } from "node:fs/promises";
|
||||
import { homedir, hostname } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -18,7 +19,44 @@ interface Record {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
const RECORDS_DIR: string = join(homedir(), ".uwf-dashboard/records");
|
||||
// Migration: Move from legacy .uwf-dashboard to .uncaged/dashboard
|
||||
const LEGACY_DIR: string = join(homedir(), ".uwf-dashboard");
|
||||
const NEW_BASE_DIR: string = join(homedir(), ".uncaged/dashboard");
|
||||
const RECORDS_DIR: string = join(NEW_BASE_DIR, "records");
|
||||
|
||||
async function migrateFromLegacy(): Promise<void> {
|
||||
if (existsSync(LEGACY_DIR) && !existsSync(NEW_BASE_DIR)) {
|
||||
console.log("Migrating from legacy .uwf-dashboard to .uncaged/dashboard...");
|
||||
await mkdir(NEW_BASE_DIR, { recursive: true });
|
||||
|
||||
// Migrate records directory if it exists
|
||||
const legacyRecordsDir = join(LEGACY_DIR, "records");
|
||||
if (existsSync(legacyRecordsDir)) {
|
||||
const files = await readdir(legacyRecordsDir);
|
||||
await mkdir(RECORDS_DIR, { recursive: true });
|
||||
|
||||
for (const file of files) {
|
||||
const oldPath = join(legacyRecordsDir, file);
|
||||
const newPath = join(RECORDS_DIR, file);
|
||||
await rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
await rmdir(legacyRecordsDir);
|
||||
}
|
||||
|
||||
// Remove legacy directory if empty
|
||||
try {
|
||||
const remaining = await readdir(LEGACY_DIR);
|
||||
if (remaining.length === 0) {
|
||||
await rmdir(LEGACY_DIR);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log("Migration complete.");
|
||||
}
|
||||
}
|
||||
|
||||
await migrateFromLegacy();
|
||||
await mkdir(RECORDS_DIR, { recursive: true });
|
||||
|
||||
const args: string[] = process.argv.slice(2);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
include: ["__tests__/**/*.test.ts"],
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>UWF Dashboard</title></head>
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Uncaged Dashboard</title></head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Uncaged Dashboard frontend - a real-time web interface for monitoring command execution",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.shazhou.work/uncaged/worker-dashboard.git",
|
||||
|
||||
@@ -57,6 +57,26 @@ h1 {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background: #2a2a4a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #3a3a5a;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #7c8aff;
|
||||
}
|
||||
mark {
|
||||
background: #fbbf24;
|
||||
color: #1a1a2e;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Frontend Branding", () => {
|
||||
it("should display 'Uncaged Dashboard' in header", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
expect(content).toContain("<h1>⚡ Uncaged Dashboard</h1>");
|
||||
expect(content).not.toContain("<h1>⚡ UWF Dashboard</h1>");
|
||||
});
|
||||
|
||||
it("should not have residual UWF references in UI strings", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
// Remove comments and check for UWF in UI strings
|
||||
const withoutComments = content.replace(/\/\/.*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
// Allow variable names but not in JSX strings
|
||||
const jsxMatches = withoutComments.match(/<[^>]*>.*?UWF.*?<\/[^>]*>/gi);
|
||||
expect(jsxMatches).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML Page Title", () => {
|
||||
it("should have 'Uncaged Dashboard' as page title", () => {
|
||||
const content = readFileSync(join(__dirname, "..", "index.html"), "utf-8");
|
||||
expect(content).toContain("<title>Uncaged Dashboard</title>");
|
||||
expect(content).not.toContain("<title>UWF Dashboard</title>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search Filtering with Highlight", () => {
|
||||
it("should contain a search input element with class 'search-input'", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
expect(content).toMatch(/<input[^>]*type=["'](text|search)["'][^>]*>/);
|
||||
expect(content).toMatch(/className=["'][^"']*search-input[^"']*["']/);
|
||||
expect(content).toMatch(/placeholder=["'][^"']*[Ss]earch[^"']*["']/);
|
||||
});
|
||||
|
||||
it("should declare a search query state variable", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
expect(content).toMatch(
|
||||
/const\s+\[\s*\w+\s*,\s*set\w+\s*\]\s*=\s*useState[<\w>]*\(\s*["']["']?\s*\)/,
|
||||
);
|
||||
// Verify it's a string state for search
|
||||
expect(content).toMatch(/useState<string>\(["''"]|useState\(["''"]/);
|
||||
});
|
||||
|
||||
it("should use useMemo to compute filtered records", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
expect(content).toContain("useMemo");
|
||||
// Check for dependency array containing records and search variable
|
||||
expect(content).toMatch(/useMemo\([\s\S]+?,\s*\[\s*records\s*,\s*\w+\s*\]\s*\)/);
|
||||
});
|
||||
|
||||
it("should implement case-insensitive filtering on command, device, and id fields", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for toLowerCase usage in filtering context
|
||||
const lowerCaseCount = (content.match(/\.toLowerCase\(\)/g) || []).length;
|
||||
expect(lowerCaseCount).toBeGreaterThanOrEqual(2); // At least for query and one field
|
||||
|
||||
// Check for includes method (used for substring matching)
|
||||
expect(content).toMatch(/\.includes\(/);
|
||||
|
||||
// Verify filtering checks command, device, and id
|
||||
expect(content).toMatch(/r\.command/);
|
||||
expect(content).toMatch(/r\.device/);
|
||||
expect(content).toMatch(/r\.id/);
|
||||
});
|
||||
|
||||
it("should define a Highlight component that wraps matches with <mark> tags", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for Highlight component definition
|
||||
expect(content).toMatch(/function\s+Highlight|const\s+Highlight\s*[:=]/);
|
||||
|
||||
// Check for mark tag usage
|
||||
expect(content).toContain("mark");
|
||||
|
||||
// Check for props handling (text and query or similar)
|
||||
expect(content).toMatch(
|
||||
/\{\s*text\s*,\s*query\s*\}|\{\s*text\s*:\s*\w+\s*,\s*query\s*:\s*\w+\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply Highlight component to command column in record rows", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for Highlight usage with command field
|
||||
expect(content).toMatch(/<Highlight[^>]*text=\{r\.command\}/);
|
||||
|
||||
// Verify it's within the command span context
|
||||
const commandSpanMatch = content.match(
|
||||
/<span[^>]*className=["']command["'][^>]*>[\s\S]*?<\/span>/,
|
||||
);
|
||||
expect(commandSpanMatch).toBeTruthy();
|
||||
if (commandSpanMatch) {
|
||||
expect(commandSpanMatch[0]).toContain("Highlight");
|
||||
}
|
||||
});
|
||||
|
||||
it("should apply Highlight component to device badge in record rows", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for Highlight usage with device field
|
||||
expect(content).toMatch(/<Highlight[^>]*text=\{r\.device\}/);
|
||||
|
||||
// Verify it's within the device-badge span context
|
||||
const deviceBadgeMatch = content.match(
|
||||
/<span[^>]*className=["']device-badge["'][^>]*>[\s\S]*?<\/span>/,
|
||||
);
|
||||
expect(deviceBadgeMatch).toBeTruthy();
|
||||
if (deviceBadgeMatch) {
|
||||
expect(deviceBadgeMatch[0]).toContain("Highlight");
|
||||
}
|
||||
});
|
||||
|
||||
it("should show 'No matches' when search has no results vs 'No records yet' when empty", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for "No records yet" message
|
||||
expect(content).toContain("No records yet");
|
||||
|
||||
// Check for "No matches" or similar search-specific empty message
|
||||
expect(content).toMatch(/No matches|No search results|No records found/i);
|
||||
});
|
||||
|
||||
it("should define .search-input styles consistent with dark theme", () => {
|
||||
const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
|
||||
|
||||
// Check for .search-input selector
|
||||
expect(cssContent).toMatch(/\.search-input\s*\{/);
|
||||
|
||||
// Extract the search-input block
|
||||
const searchInputBlock = cssContent.match(/\.search-input\s*\{[^}]+\}/);
|
||||
expect(searchInputBlock).toBeTruthy();
|
||||
|
||||
if (searchInputBlock) {
|
||||
const block = searchInputBlock[0];
|
||||
// Dark background
|
||||
expect(block).toMatch(/background:\s*#[0-9a-fA-F]{6}/);
|
||||
// Light text
|
||||
expect(block).toMatch(/color:\s*#[0-9a-fA-F]{6}/);
|
||||
// Border
|
||||
expect(block).toMatch(/border:/);
|
||||
// Padding
|
||||
expect(block).toMatch(/padding:/);
|
||||
// Border radius
|
||||
expect(block).toMatch(/border-radius:/);
|
||||
}
|
||||
});
|
||||
|
||||
it("should define mark element styles with yellow/orange background for dark theme visibility", () => {
|
||||
const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
|
||||
|
||||
// Check for mark selector
|
||||
expect(cssContent).toMatch(/\bmark\s*\{|\.mark\s*\{/);
|
||||
|
||||
// Extract the mark block
|
||||
const markBlock = cssContent.match(/\bmark\s*\{[^}]+\}/);
|
||||
expect(markBlock).toBeTruthy();
|
||||
|
||||
if (markBlock) {
|
||||
const block = markBlock[0];
|
||||
// Yellow/orange background (hex codes starting with #f, #fb, #fc, #d, #e, etc.)
|
||||
expect(block).toMatch(/background[^:]*:\s*#[def][0-9a-fA-F]{5}/);
|
||||
// Should have color defined for text
|
||||
expect(block).toMatch(/color:/);
|
||||
}
|
||||
});
|
||||
|
||||
it("should place search input above device filter buttons", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Extract the return JSX section
|
||||
const returnMatch = content.match(/return\s*\(([\s\S]+)\);?\s*\}/);
|
||||
expect(returnMatch).toBeTruthy();
|
||||
|
||||
if (returnMatch) {
|
||||
const jsx = returnMatch[1];
|
||||
|
||||
// Find position of search input
|
||||
const searchInputPos = jsx.search(/<input[^>]*search-input/);
|
||||
expect(searchInputPos).toBeGreaterThan(-1);
|
||||
|
||||
// Find position of filters div
|
||||
const filtersPos = jsx.search(/<div[^>]*className=["']filters["']/);
|
||||
expect(filtersPos).toBeGreaterThan(-1);
|
||||
|
||||
// Search input should come before filters
|
||||
expect(searchInputPos).toBeLessThan(filtersPos);
|
||||
}
|
||||
});
|
||||
|
||||
it("should render filtered records instead of raw records in the list", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for a filtered variable being mapped
|
||||
const useMemoMatch = content.match(/const\s+(\w+)\s*=\s*useMemo/);
|
||||
expect(useMemoMatch).toBeTruthy();
|
||||
|
||||
if (useMemoMatch) {
|
||||
const filteredVar = useMemoMatch[1];
|
||||
|
||||
// Check that this variable is used in .map()
|
||||
const mapPattern = new RegExp(`\\{${filteredVar}\\.map\\(`);
|
||||
expect(content).toMatch(mapPattern);
|
||||
}
|
||||
|
||||
// Additionally ensure raw records.map is NOT used in the render section
|
||||
// (after the useMemo definition)
|
||||
const useMemoIndex = content.indexOf("useMemo");
|
||||
const recordsMapMatch = content.slice(useMemoIndex).match(/\{records\.map\(/);
|
||||
expect(recordsMapMatch).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle empty search query gracefully", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check that filtering logic handles empty query
|
||||
const useMemoBlock = content.match(/useMemo\([^}]+\}/);
|
||||
expect(useMemoBlock).toBeTruthy();
|
||||
|
||||
// Should either have early return for empty query OR filter logic that handles it
|
||||
expect(content).toMatch(/if\s*\(\s*!\w+|if\s*\(\s*\w+\.trim\(\)|\.includes\(/);
|
||||
});
|
||||
|
||||
it("should import useMemo from React", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for useMemo in React import (may have React, before the destructure)
|
||||
expect(content).toMatch(/import\s+.*\{[^}]*useMemo[^}]*\}\s+from\s+["']react["']/);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import React, { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface Record {
|
||||
@@ -29,6 +29,25 @@ function fmtDuration(ms: number) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function Highlight({ text, query }: { text: string; query: string }) {
|
||||
if (!query.trim()) return <>{text}</>;
|
||||
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={`${i}-${part}`}>{part}</mark>
|
||||
) : (
|
||||
<span key={`${i}-${part}`}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [records, setRecords] = useState<Record[]>([]);
|
||||
const [workers, setWorkers] = useState<Worker[]>([]);
|
||||
@@ -36,9 +55,23 @@ export default function App() {
|
||||
[],
|
||||
);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const nav = useNavigate();
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (!searchQuery.trim()) return records;
|
||||
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
return records.filter((r) => {
|
||||
return (
|
||||
r.command.toLowerCase().includes(lowerQuery) ||
|
||||
r.device.toLowerCase().includes(lowerQuery) ||
|
||||
r.id.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
});
|
||||
}, [records, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/records${filter ? `?device=${filter}` : ""}`)
|
||||
.then((r) => r.json())
|
||||
@@ -80,7 +113,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="container">
|
||||
<header>
|
||||
<h1>⚡ UWF Dashboard</h1>
|
||||
<h1>⚡ Uncaged Dashboard</h1>
|
||||
<div className="workers">
|
||||
{workers.length > 0 ? (
|
||||
workers.map((w) => (
|
||||
@@ -94,6 +127,13 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search records by command, device, or ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="filters">
|
||||
<button type="button" className={!filter ? "active" : ""} onClick={() => setFilter("")}>
|
||||
All
|
||||
@@ -111,15 +151,22 @@ export default function App() {
|
||||
</div>
|
||||
<div className="records">
|
||||
{records.length === 0 && <div className="empty">No records yet</div>}
|
||||
{records.map((r) => (
|
||||
{records.length > 0 && filteredRecords.length === 0 && searchQuery && (
|
||||
<div className="empty">No matches found</div>
|
||||
)}
|
||||
{filteredRecords.map((r) => (
|
||||
<button
|
||||
type="button"
|
||||
key={r.id}
|
||||
className="record-row"
|
||||
onClick={() => nav(`/record/${r.id}`)}
|
||||
>
|
||||
<span className="device-badge">{r.device}</span>
|
||||
<span className="command">{r.command}</span>
|
||||
<span className="device-badge">
|
||||
<Highlight text={r.device} query={searchQuery} />
|
||||
</span>
|
||||
<span className="command">
|
||||
<Highlight text={r.command} query={searchQuery} />
|
||||
</span>
|
||||
<span className={`exit-code ${r.exitCode === 0 ? "success" : "error"}`}>
|
||||
{r.exitCode === 0 ? "✓" : `✗ ${r.exitCode}`}
|
||||
</span>
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Uncaged Dashboard server - WebSocket and REST API for aggregating command records",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.mjs",
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./protocol": "./src/protocol.mjs"
|
||||
"./protocol": {
|
||||
"types": "./dist/protocol.d.ts",
|
||||
"import": "./dist/protocol.js"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -17,11 +21,16 @@
|
||||
"directory": "packages/server"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:ci": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/ws": "^8.5.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Device, Record, Worker } from "../types";
|
||||
|
||||
describe("Server TypeScript Migration", () => {
|
||||
describe("Type Safety Tests", () => {
|
||||
it("should compile TypeScript without errors", () => {
|
||||
// This test passes if the TypeScript compiler succeeds
|
||||
// The build step will fail if there are type errors
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("should have proper Record interface", () => {
|
||||
const record: Record = {
|
||||
id: "test-id",
|
||||
device: "test-device",
|
||||
command: "echo",
|
||||
args: ["test"],
|
||||
stdout: "test output",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 100,
|
||||
};
|
||||
|
||||
expect(record.id).toBe("test-id");
|
||||
expect(typeof record.exitCode).toBe("number");
|
||||
expect(Array.isArray(record.args)).toBe(true);
|
||||
});
|
||||
|
||||
it("should have proper Worker interface", () => {
|
||||
const worker: Worker = {
|
||||
id: "worker-1",
|
||||
device: "device-1",
|
||||
connectedAt: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
};
|
||||
|
||||
expect(worker.id).toBe("worker-1");
|
||||
expect(typeof worker.device).toBe("string");
|
||||
});
|
||||
|
||||
it("should have proper Device interface", () => {
|
||||
const device: Device = {
|
||||
name: "device-1",
|
||||
recordCount: 5,
|
||||
lastSeen: new Date().toISOString(),
|
||||
online: true,
|
||||
};
|
||||
|
||||
expect(device.name).toBe("device-1");
|
||||
expect(typeof device.recordCount).toBe("number");
|
||||
expect(typeof device.online).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should have typed message interfaces", () => {
|
||||
const registerMsg = {
|
||||
type: "register" as const,
|
||||
device: "test-device",
|
||||
};
|
||||
|
||||
const recordMsg = {
|
||||
type: "record" as const,
|
||||
record: {
|
||||
id: "rec-1",
|
||||
device: "dev-1",
|
||||
command: "echo",
|
||||
args: ["test"],
|
||||
stdout: "output",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 50,
|
||||
},
|
||||
};
|
||||
|
||||
expect(registerMsg.type).toBe("register");
|
||||
expect(recordMsg.type).toBe("record");
|
||||
});
|
||||
|
||||
it("should enforce strict null checks", () => {
|
||||
const device: Device = {
|
||||
name: "test",
|
||||
recordCount: 0,
|
||||
lastSeen: null,
|
||||
online: false,
|
||||
};
|
||||
|
||||
expect(device.lastSeen).toBeNull();
|
||||
});
|
||||
|
||||
it("should have no implicit any types in compiled code", async () => {
|
||||
// This verifies TypeScript compilation with strict mode
|
||||
// If there are implicit any types, compilation will fail
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Protocol Constants", () => {
|
||||
it("should export MSG constants", async () => {
|
||||
const { MSG } = await import("../protocol");
|
||||
|
||||
expect(MSG.REGISTER).toBe("register");
|
||||
expect(MSG.RECORD).toBe("record");
|
||||
expect(MSG.WORKERS).toBe("workers");
|
||||
expect(MSG.NEW_RECORD).toBe("newRecord");
|
||||
});
|
||||
|
||||
it("should export API constants", async () => {
|
||||
const { API } = await import("../protocol");
|
||||
|
||||
expect(API.RECORDS).toBe("/api/records");
|
||||
expect(API.RECORD).toBe("/api/records/:id");
|
||||
expect(API.DEVICES).toBe("/api/devices");
|
||||
});
|
||||
|
||||
it("should export WS constants", async () => {
|
||||
const { WS } = await import("../protocol");
|
||||
|
||||
expect(WS.WORKER).toBe("/ws/worker");
|
||||
expect(WS.DASHBOARD).toBe("/ws/dashboard");
|
||||
});
|
||||
|
||||
it("should have literal string types for constants", async () => {
|
||||
const { MSG } = await import("../protocol");
|
||||
|
||||
// TypeScript will enforce these are literal types
|
||||
const msgType: "register" | "record" | "workers" | "newRecord" = MSG.REGISTER;
|
||||
expect(msgType).toBe("register");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Build Configuration", () => {
|
||||
it("should have tsconfig.json with strict mode", async () => {
|
||||
const tsconfig = JSON.parse(await readFile(join(process.cwd(), "tsconfig.json"), "utf-8"));
|
||||
|
||||
expect(tsconfig.compilerOptions.strict).toBe(true);
|
||||
expect(tsconfig.compilerOptions.noImplicitAny).toBe(true);
|
||||
expect(tsconfig.compilerOptions.strictNullChecks).toBe(true);
|
||||
});
|
||||
|
||||
it("should have tsconfig.json with proper module settings", async () => {
|
||||
const tsconfig = JSON.parse(await readFile(join(process.cwd(), "tsconfig.json"), "utf-8"));
|
||||
|
||||
expect(tsconfig.compilerOptions.module).toBe("ESNext");
|
||||
expect(tsconfig.compilerOptions.moduleResolution).toBe("bundler");
|
||||
});
|
||||
|
||||
it("should have tsconfig.json with output directory", async () => {
|
||||
const tsconfig = JSON.parse(await readFile(join(process.cwd(), "tsconfig.json"), "utf-8"));
|
||||
|
||||
expect(tsconfig.compilerOptions.outDir).toBe("./dist");
|
||||
expect(tsconfig.compilerOptions.rootDir).toBe("./src");
|
||||
});
|
||||
|
||||
it("should generate declaration files", async () => {
|
||||
const tsconfig = JSON.parse(await readFile(join(process.cwd(), "tsconfig.json"), "utf-8"));
|
||||
|
||||
expect(tsconfig.compilerOptions.declaration).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Module Exports", () => {
|
||||
it("should export types from types.ts", async () => {
|
||||
const types = await import("../types");
|
||||
|
||||
// Just verify the module loads without error
|
||||
expect(types).toBeDefined();
|
||||
});
|
||||
|
||||
it("should export protocol constants", async () => {
|
||||
const protocol = await import("../protocol");
|
||||
|
||||
expect(protocol.MSG).toBeDefined();
|
||||
expect(protocol.API).toBeDefined();
|
||||
expect(protocol.WS).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Structure Validation", () => {
|
||||
it("should validate Record structure with all required fields", () => {
|
||||
const record: Record = {
|
||||
id: "abc-123",
|
||||
device: "laptop-1",
|
||||
command: "ls",
|
||||
args: ["-la"],
|
||||
stdout: "file1\nfile2",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
startedAt: "2026-05-28T10:00:00.000Z",
|
||||
finishedAt: "2026-05-28T10:00:01.000Z",
|
||||
durationMs: 1000,
|
||||
};
|
||||
|
||||
// Verify all fields are present and correctly typed
|
||||
expect(typeof record.id).toBe("string");
|
||||
expect(typeof record.device).toBe("string");
|
||||
expect(typeof record.command).toBe("string");
|
||||
expect(Array.isArray(record.args)).toBe(true);
|
||||
expect(typeof record.stdout).toBe("string");
|
||||
expect(typeof record.stderr).toBe("string");
|
||||
expect(typeof record.exitCode).toBe("number");
|
||||
expect(typeof record.startedAt).toBe("string");
|
||||
expect(typeof record.finishedAt).toBe("string");
|
||||
expect(typeof record.durationMs).toBe("number");
|
||||
});
|
||||
|
||||
it("should validate Worker structure", () => {
|
||||
const worker: Worker = {
|
||||
id: "worker-xyz",
|
||||
device: "server-1",
|
||||
connectedAt: "2026-05-28T10:00:00.000Z",
|
||||
lastSeen: "2026-05-28T10:05:00.000Z",
|
||||
};
|
||||
|
||||
expect(typeof worker.id).toBe("string");
|
||||
expect(typeof worker.device).toBe("string");
|
||||
expect(typeof worker.connectedAt).toBe("string");
|
||||
expect(typeof worker.lastSeen).toBe("string");
|
||||
});
|
||||
|
||||
it("should validate Device structure with nullable lastSeen", () => {
|
||||
const device1: Device = {
|
||||
name: "device-1",
|
||||
recordCount: 10,
|
||||
lastSeen: "2026-05-28T10:00:00.000Z",
|
||||
online: true,
|
||||
};
|
||||
|
||||
const device2: Device = {
|
||||
name: "device-2",
|
||||
recordCount: 0,
|
||||
lastSeen: null,
|
||||
online: false,
|
||||
};
|
||||
|
||||
expect(device1.lastSeen).toBeTruthy();
|
||||
expect(device2.lastSeen).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Type Safety Enforcement", () => {
|
||||
it("should prevent assignment of wrong types to Record fields", () => {
|
||||
// This test verifies TypeScript compile-time checks
|
||||
// If types are wrong, the build will fail
|
||||
const record: Record = {
|
||||
id: "test",
|
||||
device: "dev",
|
||||
command: "cmd",
|
||||
args: ["arg1"],
|
||||
stdout: "out",
|
||||
stderr: "err",
|
||||
exitCode: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 100,
|
||||
};
|
||||
|
||||
// TypeScript ensures these are the correct types
|
||||
expect(typeof record.exitCode).toBe("number");
|
||||
expect(Array.isArray(record.args)).toBe(true);
|
||||
});
|
||||
|
||||
it("should enforce message type discrimination", () => {
|
||||
const registerMsg = {
|
||||
type: "register" as const,
|
||||
device: "test-device",
|
||||
};
|
||||
|
||||
const recordMsg = {
|
||||
type: "record" as const,
|
||||
record: {
|
||||
id: "1",
|
||||
device: "dev",
|
||||
command: "cmd",
|
||||
args: [],
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
startedAt: "",
|
||||
finishedAt: "",
|
||||
durationMs: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(registerMsg.type).toBe("register");
|
||||
expect(recordMsg.type).toBe("record");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Import Path Resolution", () => {
|
||||
it("should resolve Node.js built-in imports", async () => {
|
||||
// Verify that Node.js imports work in TypeScript
|
||||
const { mkdir: mkdirFn } = await import("node:fs/promises");
|
||||
const { join: joinFn } = await import("node:path");
|
||||
|
||||
expect(typeof mkdirFn).toBe("function");
|
||||
expect(typeof joinFn).toBe("function");
|
||||
});
|
||||
|
||||
it("should resolve third-party package imports", async () => {
|
||||
// Verify express and ws can be imported
|
||||
const express = await import("express");
|
||||
const { WebSocketServer } = await import("ws");
|
||||
|
||||
expect(express.default).toBeDefined();
|
||||
expect(WebSocketServer).toBeDefined();
|
||||
});
|
||||
|
||||
it("should resolve local module imports", async () => {
|
||||
const protocol = await import("../protocol");
|
||||
const types = await import("../types");
|
||||
|
||||
expect(protocol).toBeDefined();
|
||||
expect(types).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Constants Type Safety", () => {
|
||||
it("should have MSG as readonly object", async () => {
|
||||
const { MSG } = await import("../protocol");
|
||||
|
||||
// MSG should be an object with string values
|
||||
expect(typeof MSG).toBe("object");
|
||||
expect(typeof MSG.REGISTER).toBe("string");
|
||||
expect(typeof MSG.RECORD).toBe("string");
|
||||
expect(typeof MSG.WORKERS).toBe("string");
|
||||
expect(typeof MSG.NEW_RECORD).toBe("string");
|
||||
});
|
||||
|
||||
it("should have API as readonly object", async () => {
|
||||
const { API } = await import("../protocol");
|
||||
|
||||
expect(typeof API).toBe("object");
|
||||
expect(typeof API.RECORDS).toBe("string");
|
||||
expect(typeof API.RECORD).toBe("string");
|
||||
expect(typeof API.DEVICES).toBe("string");
|
||||
});
|
||||
|
||||
it("should have WS as readonly object", async () => {
|
||||
const { WS } = await import("../protocol");
|
||||
|
||||
expect(typeof WS).toBe("object");
|
||||
expect(typeof WS.WORKER).toBe("string");
|
||||
expect(typeof WS.DASHBOARD).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,20 @@ import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express from "express";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { MSG, WS } from "./protocol.mjs";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { MSG, WS } from "./protocol.js";
|
||||
import type {
|
||||
Device,
|
||||
Record,
|
||||
RecordMessage,
|
||||
RecordSummary,
|
||||
RegisterMessage,
|
||||
Worker,
|
||||
} from "./types.js";
|
||||
|
||||
const logger = createLogger("server");
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(__dirname, "../data/records");
|
||||
@@ -14,24 +25,28 @@ const THREE_DAYS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
const records = new Map();
|
||||
const workers = new Map();
|
||||
const dashboardClients = new Set();
|
||||
const records = new Map<string, Record>();
|
||||
const workers = new Map<string, Worker>();
|
||||
const dashboardClients = new Set<WebSocket>();
|
||||
|
||||
async function loadRecords() {
|
||||
async function loadRecords(): Promise<void> {
|
||||
try {
|
||||
const files = await readdir(DATA_DIR);
|
||||
for (const f of files) {
|
||||
if (!f.endsWith(".json")) continue;
|
||||
try {
|
||||
const data = JSON.parse(await readFile(join(DATA_DIR, f), "utf8"));
|
||||
const data = JSON.parse(await readFile(join(DATA_DIR, f), "utf8")) as Record;
|
||||
records.set(data.id, data);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Ignore invalid files
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Ignore if directory doesn't exist yet
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
async function cleanup(): Promise<void> {
|
||||
const now = Date.now();
|
||||
for (const [id, rec] of records) {
|
||||
if (now - new Date(rec.startedAt).getTime() > THREE_DAYS) {
|
||||
@@ -48,28 +63,43 @@ setInterval(cleanup, 60 * 60 * 1000);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/records", (req, res) => {
|
||||
app.get("/api/records", (req: Request, res: Response) => {
|
||||
let recs = [...records.values()];
|
||||
if (req.query.device) recs = recs.filter((r) => r.device === req.query.device);
|
||||
recs.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
|
||||
res.json(recs.map(({ stdout, stderr, ...r }) => r));
|
||||
const deviceQuery = req.query.device;
|
||||
if (deviceQuery && typeof deviceQuery === "string") {
|
||||
recs = recs.filter((r) => r.device === deviceQuery);
|
||||
}
|
||||
recs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
||||
const summaries: RecordSummary[] = recs.map(({ stdout, stderr, ...r }) => r);
|
||||
res.json(summaries);
|
||||
});
|
||||
|
||||
app.get("/api/records/:id", (req, res) => {
|
||||
const rec = records.get(req.params.id);
|
||||
if (!rec) return res.status(404).json({ error: "Not found" });
|
||||
app.get("/api/records/:id", (req: Request, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const rec = records.get(id);
|
||||
if (!rec) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
res.json(rec);
|
||||
});
|
||||
|
||||
app.get("/api/devices", (req, res) => {
|
||||
const devices = new Map();
|
||||
app.get("/api/devices", (_req: Request, res: Response) => {
|
||||
const devices = new Map<string, Device>();
|
||||
for (const rec of records.values()) {
|
||||
const d = devices.get(rec.device) || { name: rec.device, recordCount: 0, lastSeen: null };
|
||||
const d = devices.get(rec.device) || {
|
||||
name: rec.device,
|
||||
recordCount: 0,
|
||||
lastSeen: null as string | null,
|
||||
online: false,
|
||||
};
|
||||
d.recordCount++;
|
||||
if (!d.lastSeen || new Date(rec.startedAt) > new Date(d.lastSeen)) d.lastSeen = rec.startedAt;
|
||||
if (!d.lastSeen || new Date(rec.startedAt) > new Date(d.lastSeen)) {
|
||||
d.lastSeen = rec.startedAt;
|
||||
}
|
||||
devices.set(rec.device, d);
|
||||
}
|
||||
const result = [...devices.values()].map((d) => ({
|
||||
const result: Device[] = [...devices.values()].map((d) => ({
|
||||
...d,
|
||||
online: [...workers.values()].some((w) => w.device === d.name),
|
||||
}));
|
||||
@@ -77,7 +107,7 @@ app.get("/api/devices", (req, res) => {
|
||||
});
|
||||
|
||||
app.use(express.static(FRONTEND_DIR));
|
||||
app.get(/^\/(?!api|ws).*/, (req, res) => {
|
||||
app.get(/^\/(?!api|ws).*/, (_req: Request, res: Response) => {
|
||||
res.sendFile(join(FRONTEND_DIR, "index.html"));
|
||||
});
|
||||
|
||||
@@ -96,33 +126,45 @@ server.on("upgrade", (req, socket, head) => {
|
||||
}
|
||||
});
|
||||
|
||||
function broadcastWorkers() {
|
||||
function broadcastWorkers(): void {
|
||||
const workerList = [...workers.values()];
|
||||
for (const client of dashboardClients) {
|
||||
if (client.readyState === 1)
|
||||
if (client.readyState === 1) {
|
||||
client.send(JSON.stringify({ type: MSG.WORKERS, workers: workerList }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workerWss.on("connection", (ws) => {
|
||||
let workerId = null;
|
||||
ws.on("message", async (raw) => {
|
||||
workerWss.on("connection", (ws: WebSocket) => {
|
||||
let workerId: string | null = null;
|
||||
|
||||
ws.on("message", async (raw: Buffer) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw);
|
||||
const msg = JSON.parse(raw.toString()) as RegisterMessage | RecordMessage;
|
||||
|
||||
if (msg.type === MSG.REGISTER) {
|
||||
workerId = `${msg.device}-${Date.now()}`;
|
||||
const registerMsg = msg as RegisterMessage;
|
||||
workerId = `${registerMsg.device}-${Date.now()}`;
|
||||
workers.set(workerId, {
|
||||
id: workerId,
|
||||
device: msg.device,
|
||||
device: registerMsg.device,
|
||||
connectedAt: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
});
|
||||
broadcastWorkers();
|
||||
} else if (msg.type === MSG.RECORD) {
|
||||
const rec = msg.record;
|
||||
const recordMsg = msg as RecordMessage;
|
||||
const rec = recordMsg.record;
|
||||
records.set(rec.id, rec);
|
||||
await writeFile(join(DATA_DIR, `${rec.id}.json`), JSON.stringify(rec, null, 2));
|
||||
if (workerId) workers.get(workerId).lastSeen = new Date().toISOString();
|
||||
|
||||
if (workerId) {
|
||||
const worker = workers.get(workerId);
|
||||
if (worker) {
|
||||
worker.lastSeen = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
for (const client of dashboardClients) {
|
||||
if (client.readyState === 1) {
|
||||
const { stdout, stderr, ...summary } = rec;
|
||||
@@ -130,8 +172,11 @@ workerWss.on("connection", (ws) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (workerId) {
|
||||
workers.delete(workerId);
|
||||
@@ -140,10 +185,10 @@ workerWss.on("connection", (ws) => {
|
||||
});
|
||||
});
|
||||
|
||||
dashboardWss.on("connection", (ws) => {
|
||||
dashboardWss.on("connection", (ws: WebSocket) => {
|
||||
dashboardClients.add(ws);
|
||||
ws.send(JSON.stringify({ type: MSG.WORKERS, workers: [...workers.values()] }));
|
||||
ws.on("close", () => dashboardClients.delete(ws));
|
||||
});
|
||||
|
||||
server.listen(PORT, () => console.log(`Dashboard server on port ${PORT}`));
|
||||
server.listen(PORT, () => logger.info(`Dashboard server on port ${PORT}`));
|
||||
@@ -0,0 +1,17 @@
|
||||
// Simple logger utility for server startup messages
|
||||
// Note: @uncaged/workflow-util does not exist in this monorepo,
|
||||
// so we use a minimal logger that wraps console for consistency
|
||||
|
||||
export interface Logger {
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
}
|
||||
|
||||
export function createLogger(name: string): Logger {
|
||||
return {
|
||||
info: (message: string) => console.log(`[${name}] ${message}`),
|
||||
error: (message: string) => console.error(`[${name}] ${message}`),
|
||||
warn: (message: string) => console.warn(`[${name}] ${message}`),
|
||||
};
|
||||
}
|
||||
@@ -3,15 +3,19 @@ export const MSG = {
|
||||
RECORD: "record",
|
||||
WORKERS: "workers",
|
||||
NEW_RECORD: "newRecord",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const API = {
|
||||
RECORDS: "/api/records",
|
||||
RECORD: "/api/records/:id",
|
||||
DEVICES: "/api/devices",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const WS = {
|
||||
WORKER: "/ws/worker",
|
||||
DASHBOARD: "/ws/dashboard",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type MessageType = (typeof MSG)[keyof typeof MSG];
|
||||
export type ApiEndpoint = (typeof API)[keyof typeof API];
|
||||
export type WebSocketEndpoint = (typeof WS)[keyof typeof WS];
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface Record {
|
||||
id: string;
|
||||
device: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface RecordSummary {
|
||||
id: string;
|
||||
device: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
exitCode: number;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface Worker {
|
||||
id: string;
|
||||
device: string;
|
||||
connectedAt: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
name: string;
|
||||
recordCount: number;
|
||||
lastSeen: string | null;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterMessage {
|
||||
type: "register";
|
||||
device: string;
|
||||
}
|
||||
|
||||
export interface RecordMessage {
|
||||
type: "record";
|
||||
record: Record;
|
||||
}
|
||||
|
||||
export interface WorkersMessage {
|
||||
type: "workers";
|
||||
workers: Worker[];
|
||||
}
|
||||
|
||||
export interface NewRecordMessage {
|
||||
type: "newRecord";
|
||||
record: RecordSummary;
|
||||
}
|
||||
|
||||
export type WebSocketMessage = RegisterMessage | RecordMessage | WorkersMessage | NewRecordMessage;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user