Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .opencode/skills/live-preview/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
name: live-preview
description: Expose a dev server running in this worktree via the Sprite's public URL
user-invocable: false
allowed-tools: Bash(socat *), Bash(kill *), Bash(lsof *), Bash(nohup *), Bash(npm *), Bash(pnpm *), Bash(yarn *), Bash(npx *), Bash(python *), Bash(mix *)
---

## Live Preview

When this project has a dev server (web frontend, API, etc.), you can expose it
to the user via the Sprite's public URL. The Sprite routes all HTTP traffic
arriving at port **8080** to `https://<sprite-name>-<org>.sprites.dev/`.

Your preview URL is available in `$SPRITE_PREVIEW_URL` (empty when running locally).

### How it works

Multiple worktrees share a single Sprite. Each runs its dev server on a
**different port**. A `socat` forwarder on port 8080 points to whichever
worktree the user wants to preview. You control which one is active.

### Step-by-step

1. **Start the dev server** on any free port (not 8080):

```bash
# Pick a deterministic port from the issue id to avoid collisions
PORT=$((30000 + ($RANDOM % 30000)))
# Example: Next.js
nohup npm run dev -- --port $PORT > /tmp/dev-server.log 2>&1 &
DEV_PID=$!
echo "Dev server PID=$DEV_PID on port $PORT"
```

Adapt the command to the project's framework (vite, next, mix phx.server, etc.).

2. **Wait for the server to be ready** before forwarding:

```bash
for i in $(seq 1 30); do
curl -s http://localhost:$PORT > /dev/null && break
sleep 1
done
```

3. **Forward port 8080 → your dev server port**:

```bash
# Kill any existing forwarder on 8080
lsof -ti :8080 | xargs -r kill -9 2>/dev/null || true
# Start socat forwarder
nohup socat TCP-LISTEN:8080,fork,reuseaddr TCP:localhost:$PORT > /tmp/socat.log 2>&1 &
SOCAT_PID=$!
echo "Forwarding 8080 → $PORT (socat PID=$SOCAT_PID)"
```

4. **Report the preview URL** so the user can open it:

```bash
echo "Preview: $SPRITE_PREVIEW_URL"
```

Also include the URL in your heartbeat messages so it shows in the dashboard:

```bash
curl -s -X POST "$SYNKADE_API_URL/heartbeat" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"issue_id":"<issue_id>","status":"working","message":"Preview live at '$SPRITE_PREVIEW_URL'"}'
```

5. **Cleanup** when done — stop the forwarder and dev server:

```bash
kill $SOCAT_PID $DEV_PID 2>/dev/null || true
```

### Important notes

- **Only one worktree can own port 8080 at a time.** Your `lsof | kill` in step 3
takes over from whoever had it before. This is expected — the user requested
your branch.
- If `socat` is not installed, install it: `apt-get update && apt-get install -y socat`
- If `$SPRITE_PREVIEW_URL` is empty, you are running on the local backend —
just print `http://localhost:$PORT` instead.
- The Sprite URL requires authentication by default (org members + API tokens).
The user can make it public from the Synkade settings if needed.
93 changes: 93 additions & 0 deletions .opencode/skills/synkade/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
name: synkade
description: Git workflow, Synkade API, status reporting, and pull-based agent protocol
user-invocable: false
allowed-tools: Bash(git *), Bash(gh *), Bash(curl *)
---

## Git & Pull Requests

You have a `GITHUB_TOKEN` environment variable. After making changes, commit and open a PR:

```bash
git checkout -b fix/short-description
git add -A && git commit -m "Description of changes"
gh pr create --title "Short title" --body "Description"
```

Always create a PR with your changes so they can be reviewed.

## Synkade API

Environment variables: `$SYNKADE_API_URL`, `$SYNKADE_API_TOKEN`.

```bash
# List issues for this project
curl -s -H "Authorization: Bearer $SYNKADE_API_TOKEN" \
"$SYNKADE_API_URL/issues?project_id=aafde329-f689-4d47-9df9-15457b4a86f3"

# Create an issue
curl -s -X POST "$SYNKADE_API_URL/issues" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"aafde329-f689-4d47-9df9-15457b4a86f3","body":"# Title\n\nDetails"}'

# Update an issue
curl -s -X PATCH "$SYNKADE_API_URL/issues/<issue_id>" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"done"}'

# Read issue history
curl -s -H "Authorization: Bearer $SYNKADE_API_TOKEN" \
"$SYNKADE_API_URL/issues/<issue_id>"
```

## Status Reporting

Send heartbeats every 2-3 minutes during long tasks to prevent stall detection:

```bash
curl -s -X POST "$SYNKADE_API_URL/heartbeat" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"issue_id":"<issue_id>","status":"working","message":"Brief status"}'
```

Valid statuses: `working`, `error`, `blocked`.

## Follow-Up Issues

When you discover out-of-scope work (bugs, tech debt, follow-ups), create issues rather than scope-creeping:

```bash
curl -s -X POST "$SYNKADE_API_URL/issues" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"aafde329-f689-4d47-9df9-15457b4a86f3","body":"# Issue Title\n\nDescription"}'
```

## Pull-Based Protocol

For persistent agents: discover and claim work between tasks.

```bash
# Who am I?
curl -s -H "Authorization: Bearer $SYNKADE_API_TOKEN" "$SYNKADE_API_URL/me"

# Find queued work assigned to me
curl -s -H "Authorization: Bearer $SYNKADE_API_TOKEN" \
"$SYNKADE_API_URL/issues?state=queued&assigned_to=me"

# Claim an issue (409 if already claimed)
curl -s -X POST -H "Authorization: Bearer $SYNKADE_API_TOKEN" \
"$SYNKADE_API_URL/issues/<issue_id>/checkout"

# Mark complete
curl -s -X PATCH "$SYNKADE_API_URL/issues/<issue_id>" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"awaiting_review","agent_output":"Summary of work"}'
```

Workflow: poll → checkout → heartbeat → complete.
7 changes: 6 additions & 1 deletion lib/synkade/execution/agent_runner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,12 @@ defmodule Synkade.Execution.AgentRunner do

session_id = session.session_id

case BackendClient.continue_agent(config, session_id, continuation_prompt, session.env_ref) do
case BackendClient.continue_agent(
config,
session_id,
continuation_prompt,
session.env_ref
) do
{:ok, new_session} ->
event_loop(project, issue, new_session, config, max_turns, turn + 1)

Expand Down
40 changes: 31 additions & 9 deletions lib/synkade/issues.ex
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,31 @@ defmodule Synkade.Issues do
end

def get_issue!(id) do
Issue
|> Repo.get!(id)
|> Repo.preload(children: children_preload_query())
issue = get_issue(id) |> Repo.preload(children: children_preload_query())

unless issue do
raise Ecto.NoResultsError, queryable: Issue
end

issue
end

def get_issue(id) when is_binary(id) do
with {:ok, uuid} <- Ecto.UUID.cast(id) do
Repo.get(Issue, uuid)
else
_ -> get_issue_by_short_id(id)
end
end

def get_issue(id) do
Repo.get(Issue, id)
def get_issue(_), do: nil

defp get_issue_by_short_id(short_id) when is_binary(short_id) do
short_id_pattern = "#{short_id}%"

Issue
|> where([i], like(fragment("CAST(? AS TEXT)", i.id), ^short_id_pattern))
|> Repo.one()
end

def create_issue(attrs) do
Expand Down Expand Up @@ -259,7 +277,9 @@ defmodule Synkade.Issues do
def dispatch_issue(%Issue{} = issue, dispatch_message, assigned_agent_id \\ nil) do
{agent_name, agent_kind} =
case assigned_agent_id do
nil -> {nil, nil}
nil ->
{nil, nil}

id ->
try do
agent = Synkade.Settings.get_agent!(id)
Expand All @@ -269,7 +289,8 @@ defmodule Synkade.Issues do
end
end

messages = (issue.metadata["messages"] || [])
messages = issue.metadata["messages"] || []

new_entry = %{
"type" => "dispatch",
"agent_name" => agent_name,
Expand Down Expand Up @@ -313,7 +334,8 @@ defmodule Synkade.Issues do

@doc "Appends an agent output entry to the issue message history."
def append_agent_output(%Issue{} = issue, agent_output, agent_name \\ nil, agent_kind \\ nil) do
messages = (issue.metadata["messages"] || [])
messages = issue.metadata["messages"] || []

new_entry = %{
"type" => "agent",
"agent_name" => agent_name,
Expand Down Expand Up @@ -364,7 +386,7 @@ defmodule Synkade.Issues do

@doc "Cycles a recurring issue from done back to queued, appending a system message."
def cycle_recurring_issue(%Issue{state: "done", recurring: true} = issue) do
messages = (issue.metadata["messages"] || [])
messages = issue.metadata["messages"] || []

new_entry = %{
"type" => "system",
Expand Down
23 changes: 17 additions & 6 deletions lib/synkade/issues/issue.ex
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,23 @@ defmodule Synkade.Issues.Issue do
unit = get_field(changeset, :recurrence_unit)

case {interval, unit} do
{nil, _} -> changeset
{_, nil} -> changeset
{i, "hours"} when i < 1 -> add_error(changeset, :recurrence_interval, "must be at least 1 hour")
{i, "days"} when i < 1 -> add_error(changeset, :recurrence_interval, "must be at least 1 day")
{i, "weeks"} when i < 1 -> add_error(changeset, :recurrence_interval, "must be at least 1 week")
_ -> changeset
{nil, _} ->
changeset

{_, nil} ->
changeset

{i, "hours"} when i < 1 ->
add_error(changeset, :recurrence_interval, "must be at least 1 hour")

{i, "days"} when i < 1 ->
add_error(changeset, :recurrence_interval, "must be at least 1 day")

{i, "weeks"} when i < 1 ->
add_error(changeset, :recurrence_interval, "must be at least 1 week")

_ ->
changeset
end
end
end
14 changes: 12 additions & 2 deletions lib/synkade/settings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,22 @@ defmodule Synkade.Settings do

@doc "Lists enabled projects for the scoped user."
def list_enabled_projects(%Scope{user: user}) do
Repo.all(from(p in Project, where: p.user_id == ^user.id and p.enabled == true, order_by: [asc: p.name]))
Repo.all(
from(p in Project,
where: p.user_id == ^user.id and p.enabled == true,
order_by: [asc: p.name]
)
)
end

@doc "Lists enabled projects for a user_id (for workers, no Scope)."
def list_enabled_projects_for_user(user_id) do
Repo.all(from(p in Project, where: p.user_id == ^user_id and p.enabled == true, order_by: [asc: p.name]))
Repo.all(
from(p in Project,
where: p.user_id == ^user_id and p.enabled == true,
order_by: [asc: p.name]
)
)
end

@doc "Gets a single project by ID. Raises if not found."
Expand Down
18 changes: 15 additions & 3 deletions lib/synkade/token_usage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ defmodule Synkade.TokenUsage do

@doc "Upsert today's token counts for a model (increment)."
def record_usage(user_id, model, input_tokens, output_tokens)
when is_integer(user_id) and is_binary(model) and is_integer(input_tokens) and is_integer(output_tokens) do
when is_integer(user_id) and is_binary(model) and is_integer(input_tokens) and
is_integer(output_tokens) do
today = Date.utc_today()

Repo.insert(
%__MODULE__{user_id: user_id, date: today, model: model, input_tokens: input_tokens, output_tokens: output_tokens},
%__MODULE__{
user_id: user_id,
date: today,
model: model,
input_tokens: input_tokens,
output_tokens: output_tokens
},
on_conflict:
from(t in __MODULE__,
update: [
Expand All @@ -57,7 +64,12 @@ defmodule Synkade.TokenUsage do
from(t in __MODULE__,
where: t.user_id == ^user_id and t.date >= ^cutoff,
order_by: [asc: t.date, asc: t.model],
select: %{date: t.date, model: t.model, input_tokens: t.input_tokens, output_tokens: t.output_tokens}
select: %{
date: t.date,
model: t.model,
input_tokens: t.input_tokens,
output_tokens: t.output_tokens
}
)
|> Repo.all()
end
Expand Down
3 changes: 1 addition & 2 deletions lib/synkade/workspace/git.ex
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,7 @@ defmodule Synkade.Workspace.Git do

line_count = content |> String.split("\n") |> length()

{:ok,
"--- /dev/null\n+++ b/#{filename}\n@@ -0,0 +1,#{line_count} @@\n#{lines}"}
{:ok, "--- /dev/null\n+++ b/#{filename}\n@@ -0,0 +1,#{line_count} @@\n#{lines}"}

{:error, reason} ->
{:error, reason}
Expand Down
4 changes: 3 additions & 1 deletion lib/synkade/workspace/manager.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ defmodule Synkade.Workspace.Manager do
File.mkdir_p!(worktree_path)

case run_after_create_hook(config, worktree_path) do
:ok -> {:ok, workspace}
:ok ->
{:ok, workspace}

{:error, reason} ->
File.rm_rf!(worktree_path)
{:error, reason}
Expand Down
Loading