Skip to content
Merged
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
36 changes: 30 additions & 6 deletions bin/dispatch-share
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
dispatch-share <image-path> [name]
dispatch-share --sim [udid] [name]
dispatch-share <image-path> [name] [-d "description"]
dispatch-share --sim [udid] [name] [-d "description"]

Examples:
dispatch-share ./artifacts/homepage.png
dispatch-share ./artifacts/homepage.png playwright-home.png
dispatch-share ./artifacts/homepage.png -d "Homepage after login flow"
dispatch-share --sim
dispatch-share --sim booted ios-home.png
USAGE
Expand All @@ -25,6 +26,23 @@ fi

timestamp="$(date +%Y%m%d-%H%M%S)"

# Extract -d/--description from args
DESCRIPTION=""
ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--description)
DESCRIPTION="${2:-}"
shift 2
;;
*)
ARGS+=("$1")
shift
;;
esac
done
set -- "${ARGS[@]}"

SOURCE_PATH=""
SOURCE_TYPE="screenshot"
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" || "${1:-}" == "" ]]; then
Expand Down Expand Up @@ -69,9 +87,15 @@ ext="${safe_name##*.}"
base="${safe_name%.*}"
upload_name="${base}-${timestamp}.${ext}"

response="$(curl -fsS -X POST \
-F "file=@${SOURCE_PATH};filename=${upload_name}" \
-F "source=${SOURCE_TYPE}" \
"${API_BASE}/api/v1/agents/${AGENT_ID}/media")"
CURL_ARGS=(
-fsS -X POST
-F "source=${SOURCE_TYPE}"
)
if [[ -n "$DESCRIPTION" ]]; then
CURL_ARGS+=(-F "description=${DESCRIPTION}")
fi
CURL_ARGS+=(-F "file=@${SOURCE_PATH};filename=${upload_name}")

response="$(curl "${CURL_ARGS[@]}" "${API_BASE}/api/v1/agents/${AGENT_ID}/media")"

echo "$response"
81 changes: 81 additions & 0 deletions bin/dispatch-stream
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail

usage() {
cat <<'USAGE'
Usage:
dispatch-stream start --playwright <port>
dispatch-stream stop [-d "description"]

Examples:
dispatch-stream start --playwright 62816
dispatch-stream stop
dispatch-stream stop -d "Browsing bradharris.dev homepage"
USAGE
}

if [ "${1:-}" = "" ] || [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi

ACTION="${1:-}"
shift

AGENT_ID="${DISPATCH_AGENT_ID:-${HOSTESS_AGENT_ID:-}}"
if [ -z "$AGENT_ID" ]; then
echo "DISPATCH_AGENT_ID is not set. Run this command inside a Dispatch agent session." >&2
exit 1
fi

PORT="${DISPATCH_PORT:-8787}"
API_BASE="${DISPATCH_API_BASE:-http://127.0.0.1:${PORT}}"

case "$ACTION" in
start)
if [ "${1:-}" != "--playwright" ]; then
echo "Usage: dispatch-stream start --playwright <port>" >&2
exit 1
fi
CDP_PORT="${2:-}"
if [ -z "$CDP_PORT" ]; then
echo "Port is required for --playwright." >&2
exit 1
fi
PAYLOAD="{\"type\":\"playwright\",\"port\":${CDP_PORT}}"
;;
stop)
DESCRIPTION=""
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--description)
DESCRIPTION="${2:-}"
shift 2
;;
*)
shift
;;
esac
done
if [ -n "$DESCRIPTION" ]; then
# Escape double quotes in description for JSON
ESCAPED="$(echo "$DESCRIPTION" | sed 's/"/\\"/g')"
PAYLOAD="{\"type\":\"stop\",\"description\":\"${ESCAPED}\"}"
else
PAYLOAD='{"type":"stop"}'
fi
;;
*)
echo "Invalid action: $ACTION" >&2
usage
exit 1
;;
esac

curl -fsS \
-X POST \
-H "content-type: application/json" \
--data "$PAYLOAD" \
"${API_BASE}/api/v1/agents/${AGENT_ID}/stream" >/dev/null

echo "Stream ${ACTION}: ${AGENT_ID}"
157 changes: 157 additions & 0 deletions docs/db-backed-media-store-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# DB-Backed Media Store — Implementation Plan

## Overview

Replace the filesystem-watcher-based media discovery system with a DB-backed media store. Every shared artifact (screenshots via `dispatch-share`, stream captures, simulator screenshots) gets a row in a `media` table at creation time. Media listing becomes a DB query instead of `readdir` + `fs.watch`.

## Motivation

- **Eliminate filesystem polling**: The current `fs.watch` + debounce machinery is fragile (platform-specific behavior, race conditions on rapid writes, no ordering guarantees).
- **Source tagging**: A `source` column naturally distinguishes screenshots, stream captures, and simulator captures — no filename conventions needed.
- **Single source of truth**: DB record is the authority; the file on disk is just storage.
- **Foundation for stream captures**: The Playwright streaming feature needs to save a last-frame capture when a stream ends. With a DB record, the frontend knows it's a stream capture without guessing.

## DB Migration

```sql
CREATE TABLE IF NOT EXISTS media (
id SERIAL PRIMARY KEY,
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'screenshot', -- 'screenshot' | 'stream' | 'simulator'
size_bytes INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_media_agent_id ON media(agent_id);
```

No changes to the existing `media_seen` table — it continues to reference media keys (which can be derived from `file_name:created_at`).

## New API Endpoint

### `POST /api/v1/agents/:id/media`

Accepts a multipart file upload. Saves the file to the agent's media directory and inserts a DB record.

**Request**: `multipart/form-data` with fields:
- `file` (required): The image file
- `source` (optional): `"screenshot"` (default), `"stream"`, `"simulator"`

**Response**: `{ ok: true, media: { id, fileName, source, sizeBytes, createdAt, url } }`

This endpoint replaces the implicit "copy file to directory" pattern.

## Changes to `dispatch-share`

The shell script currently copies files directly to `$DISPATCH_MEDIA_DIR`. It needs to POST to the new upload endpoint instead.

```bash
# Before (filesystem copy):
cp "$SOURCE_PATH" "$dest_file"

# After (API upload):
curl -fsS -X POST \
-F "file=@${SOURCE_PATH};filename=${safe_name}" \
-F "source=screenshot" \
"${API_BASE}/api/v1/agents/${AGENT_ID}/media"
```

The script still prints the file path for backward compatibility. The server returns it in the response.

## Changes to `src/server.ts`

### Remove filesystem watcher machinery

Delete:
- `mediaWatchers` Map
- `mediaDebounceTimers` Map
- `ensureMediaWatch()` function
- All `ensureMediaWatch()` call sites (agent create, agent list, SSE subscribe)

### Replace `listMediaFiles()`

```ts
// Before: readdir + stat for each file
// After: single DB query
async function listMediaFiles(agentId: string): Promise<MediaRow[]> {
const result = await pool.query(
`SELECT file_name, source, size_bytes, created_at
FROM media WHERE agent_id = $1
ORDER BY created_at DESC LIMIT 50`,
[agentId]
);
return result.rows.map(row => ({
name: row.file_name,
source: row.source,
size: row.size_bytes,
updatedAt: row.created_at.toISOString(),
url: `/api/v1/agents/${agentId}/media/${encodeURIComponent(row.file_name)}`
}));
}
```

### Media change notifications

The upload endpoint publishes `media.changed` via `UiEventBroker` after inserting the record — same SSE event, just triggered by the API call instead of the filesystem watcher.

### Serve media files (unchanged)

`GET /api/v1/agents/:id/media/:file` continues to serve files from disk. No change needed.

## Frontend Changes

### `MediaFile` type

```ts
export type MediaFile = {
name: string;
size: number;
updatedAt: string;
url: string;
seen?: boolean;
source?: "screenshot" | "stream" | "simulator";
};
```

### Media sidebar rendering

Stream captures can show a distinct label/badge (e.g., "Stream recording" vs "Shared: filename").

## Data Migration

Existing media files on disk need records inserted into the new table. A one-time migration script:

```ts
// For each agent with a media directory:
// readdir the directory
// For each image file, insert a media record with source='screenshot'
// Use file mtime as created_at
```

This runs as part of the DB migration (or as a separate step after deploy).

## Stream Capture Integration

With the DB-backed store in place, saving a stream capture becomes:
1. `StreamManager.stopStream()` saves the last JPEG frame to disk
2. Server inserts a `media` record with `source: 'stream'`
3. Publishes `media.changed` SSE event
4. Frontend renders it with "Stream recording" label

## File Change Summary

| File | Change | Description |
|---|---|---|
| `src/db/migrate.ts` | Modify | Add `media` table, run data migration |
| `src/server.ts` | Modify | Add upload endpoint, remove fs watchers, update `listMediaFiles` |
| `bin/dispatch-share` | Modify | POST to API instead of filesystem copy |
| `web/src/components/app/types.ts` | Modify | Add `source` field to `MediaFile` |
| `web/src/components/app/media-sidebar.tsx` | Modify | Render source-specific labels |

## Gotchas

1. **Backward compat**: Old `dispatch-share` scripts (in running agent sessions) still copy files directly. The migration should handle discovering these files.
2. **`media_seen` keys**: Currently `name:updatedAt`. Ensure the new `created_at` timestamp produces the same key format so seen state isn't lost.
3. **File cleanup**: When an agent is deleted, `ON DELETE CASCADE` removes DB records. Files on disk still need cleanup (existing behavior via `mediaDir` removal).
4. **Upload size limits**: Fastify's default body size limit may need raising for the multipart upload endpoint.
Loading
Loading