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
51 changes: 51 additions & 0 deletions .claude/skills/run-app/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
name: run-app
description: Launch the php-claw dashboard — the TrueAsync API server plus the React/Vite UI. Use when asked to run, start, serve, or screenshot the app/dashboard/UI.
---

# Running the php-claw dashboard

Two processes: the PHP **API server** (`claw serve`) and the **Vite UI** dev
server. The UI proxies `/api` → the API, so the browser only ever talks to Vite.

## Key gotcha (why this took so long the first time)

`claw serve` needs the **TrueAsync server extension** (`true_async_server.so`),
which provides `TrueAsync\HttpServer`. The base `true_async` extension is loaded
by `/usr/local/lib/php.ini`, but the **server** extension is NOT:
`php --ini` shows the scan-dir is `(none)`, so `/etc/php.d/true_async_server.ini`
is never read. You must load it explicitly with `-d extension=...`.

The correct `.so` is the one already in PHP's `extension_dir`
(`/usr/local/lib/php/extensions/debug-zts-20250926/`), so the bare name works —
do NOT point at the build dirs under `~/php-http-server`, they are ABI-mismatched
and fail with "These options need to match".

## Launch

```bash
# 1. API server (port 8787) — run from the repo root, in the background
cd /home/edmond/php-claw
php -d extension=true_async_server.so bin/claw serve --port 8787 --host 127.0.0.1

# 2. UI (port 5173) — separate process
cd /home/edmond/php-claw-ui
npm run dev
```

Then open **http://127.0.0.1:5173/** (WSL2 → forwards to Windows browser).

## Smoke test

```bash
curl -s http://127.0.0.1:8787/api/projects # API direct → JSON list of projects
curl -s http://127.0.0.1:5173/api/projects # through the Vite proxy → same JSON
```

Note the routes live under `/api` (e.g. `/api/projects`), not `/projects`.

## Notes

- UI repo: `/home/edmond/php-claw-ui` (separate git repo, React 19 + Vite 6).
- Override the API target for the proxy with `CLAW_API=http://host:port npm run dev`.
- Projects served come from `workspace/projects` (the demo/katas/sample projects).
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@

# Agent tooling (Serena MCP — auto-generated config + cache)
/.serena/

# Agent tooling (local Claude Code settings)
/.claude/settings.local.json
4 changes: 2 additions & 2 deletions src/HttpGateSpeaker.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Claw\Agent\SpeakerInterface;
use Claw\Agent\SpeakerRole;
use Claw\Project\IssueStatus;
use Claw\Project\ProjectStore;
use Claw\Project\ProjectStoreInterface;
use Claw\Trace\Tracer;

/**
Expand All @@ -28,7 +28,7 @@
/** @param Channel<string> $answers the live wakeup — POST .../answer sends the human reply here */
public function __construct(
private Tracer $tracer,
private ProjectStore $store,
private ProjectStoreInterface $store,
private string $issueId,
private Channel $answers,
) {
Expand Down
2 changes: 2 additions & 0 deletions src/Project/IssueStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ enum IssueStatus: string
case WaitingHuman = 'waiting';
case Done = 'done';
case Closed = 'closed';
/** Soft-deleted: kept in the db (history + runs preserved) but hidden from the board. */
case Deleted = 'deleted';

/**
* Resolve a case by its name (how the status round-trips through the project db). An unrecognized
Expand Down
39 changes: 29 additions & 10 deletions src/Project/ProjectStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@
* kept inside the app's own home and keyed by the folder's absolute path (the way `.claude`
* keys its per-project data by path).
*
* An instance is a HANDLE to one already-resolved project: it holds the open \PDO and the
* project's metadata, so a whole command runs against a single connection. The path is
* resolved and the db opened exactly ONCE — by {@see discover()} or {@see init()} — not on
* every operation; that same connection ({@see pdo()}) also backs the durable workflow-run
* state store and the trace store, so a run never reopens its own db.
* An instance is a HANDLE to one already-resolved project: it holds an open \PDO and the
* project's metadata. The \PDO has TrueAsync's connection pool enabled ({@see open()}), so the
* one handle is safe to share across concurrent coroutines — the dashboard's reads and a detached
* run's writes — without a connection per caller. The path is resolved and the db opened exactly
* ONCE — by {@see discover()} or {@see init()} — not on every operation; that same handle
* ({@see pdo()}) also backs the durable workflow-run state store and the trace store.
*/
final class ProjectStore
final class ProjectStore implements ProjectStoreInterface
{
private function __construct(
private readonly \PDO $pdo,
Expand Down Expand Up @@ -154,7 +155,8 @@ public function project(): Project
*/
public function allIssues(): array
{
$stmt = $this->pdo->query('SELECT id, title, description, status FROM issues ORDER BY id');
// soft-deleted issues stay in the db (history + runs) but are hidden from the board
$stmt = $this->pdo->query("SELECT id, title, description, status FROM issues WHERE status != 'Deleted' ORDER BY id");
$rows = $stmt === false ? [] : $stmt->fetchAll(\PDO::FETCH_ASSOC);

return array_values(array_map(
Expand Down Expand Up @@ -358,9 +360,26 @@ private static function dbPath(string $projectsDir, string $id): string

private static function open(string $dbPath): \PDO
{
$pdo = new \PDO('sqlite:' . $dbPath);
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA busy_timeout=4000'); // ride out a concurrent writer (the run) rather than fail
// TrueAsync's built-in PDO pool hands each coroutine its own connection on demand, so ONE shared
// handle is safe to use from concurrent runs and dashboard reads at once — no manual per-run
// connection. The pool keeps a coroutine's connection pinned across awaits, so a bare
// INSERT + lastInsertId() stays correct (verified).
//
// Everything that must apply to EVERY pooled connection goes in the construction options, since
// the pool reuses them per connection: ATTR_TIMEOUT is the busy timeout (a post-open `PRAGMA
// busy_timeout` would reach only the one connection that ran it). WAL is the exception — it is
// persisted in the db header, so a single PRAGMA sets it for every connection and every restart.
//
// ATTR_POOL_* are TrueAsync's PDO additions; PHPStan reads the core \PDO stub (the ide-helper's
// \PDO redeclaration is deliberately not scanned — see phpstan.dist.neon), so each is ignored.
$pdo = new \PDO('sqlite:' . $dbPath, null, null, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_TIMEOUT => 4, // busy timeout (s) — ride out a concurrent writer rather than fail
\PDO::ATTR_POOL_ENABLED => true, // @phpstan-ignore classConstant.notFound
\PDO::ATTR_POOL_MIN => 1, // @phpstan-ignore classConstant.notFound
\PDO::ATTR_POOL_MAX => 8, // @phpstan-ignore classConstant.notFound
]);
$pdo->exec('PRAGMA journal_mode=WAL');

return $pdo;
}
Expand Down
69 changes: 69 additions & 0 deletions src/Project/ProjectStoreInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Claw\Project;

/**
* A project's state store: its issues, the runs spawned for them, and the connection that backs the
* trace and run-state stores. {@see ProjectStore} is the SQLite implementation; this seam is what the
* dashboard and the runner depend on, so a different backend (Postgres/MySQL) can drop in without them
* knowing which db they hit.
*
* Caveat: {@see pdo()} still hands out a raw \PDO — the trace and workflow-state stores read SQL straight
* off it — so a real backend swap also means abstracting those. This interface covers the issue/run
* surface; it does not by itself make the whole app database-agnostic.
*/
interface ProjectStoreInterface
{
/** The opened project's metadata. */
public function project(): Project;

/**
* Every issue in the project, oldest first (soft-deleted ones hidden).
*
* @return list<Issue>
*/
public function allIssues(): array;

/**
* The runs spawned for an issue, oldest first, with their status.
*
* @return list<array{id: string, workflow: string, status: string}>
*/
public function runsFor(string $issueId): array;

/** The shared connection backing the run-state store and the tracer. */
public function pdo(): \PDO;

/**
* Open a new issue and return it — the store assigns its id.
*
* @throws \Claw\Exceptions\ClawException
*/
public function addIssue(string $title, string $description = ''): Issue;

/**
* Load one issue with the ids of the runs spawned for it.
*
* @throws \Claw\Exceptions\ClawException
*/
public function loadIssue(string $issueId): Issue;

/** Record a run spawned for an issue and return its store-assigned id. */
public function recordRun(string $issueId, string $workflow, RunStatus $status = RunStatus::Running): string;

public function setRunStatus(string $runId, RunStatus $status): void;

/**
* Recent runs from the ledger, newest first.
*
* @return list<array{id: string, issue: string, workflow: string, status: string}>
*/
public function recentRuns(int $limit = 20): array;

/** The id of an interrupted (still-Running) run for this issue's workflow, newest first, or null. */
public function resumableRun(string $issueId, string $workflow): ?string;

public function setIssueStatus(string $issueId, IssueStatus $status): void;
}
4 changes: 2 additions & 2 deletions src/Run/HttpRunFrontend.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Async\Channel;
use Claw\Agent\SpeakerInterface;
use Claw\HttpGateSpeaker;
use Claw\Project\ProjectStore;
use Claw\Project\ProjectStoreInterface;
use Claw\Trace\LiveTraceSink;
use Claw\Trace\TraceBus;
use Claw\Trace\Tracer;
Expand All @@ -23,7 +23,7 @@
{
/** @param Channel<string> $answers the open gate's answer channel — POST .../answer sends the reply here */
public function __construct(
private ProjectStore $store,
private ProjectStoreInterface $store,
private string $issueId,
private Channel $answers,
private TraceBus $bus,
Expand Down
4 changes: 2 additions & 2 deletions src/Run/IssueRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
use Claw\Exceptions\WorkflowFinished;
use Claw\Project\Issue;
use Claw\Project\IssueStatus;
use Claw\Project\ProjectStore;
use Claw\Project\ProjectStoreInterface;
use Claw\Project\RunStatus;
use Claw\Tool\RecallTool;
use Claw\Tool\ToolFactory;
Expand Down Expand Up @@ -71,7 +71,7 @@

public function __construct(
private string $projectsDir,
private ProjectStore $store,
private ProjectStoreInterface $store,
private Config $config,
private AgentInterface $agent,
private RunFrontendInterface $frontend,
Expand Down
4 changes: 2 additions & 2 deletions src/Run/RunContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use Claw\Project\Issue;
use Claw\Project\Project;
use Claw\Project\ProjectStore;
use Claw\Project\ProjectStoreInterface;
use Claw\Trace\Tracer;
use Claw\Workflow\Environment;
use Claw\Workflow\WorkflowStore;
Expand All @@ -22,7 +22,7 @@
public function __construct(
public Environment $env,
public Tracer $tracer,
public ProjectStore $store,
public ProjectStoreInterface $store,
public WorkflowStore $workflowStore,
public string $runId,
public Issue $issue,
Expand Down
Loading
Loading