From 66eabbafc6ff437d3f9af0449c2982e98c6ace70 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:21:11 +0000 Subject: [PATCH 1/4] feat(server): close and stop issue endpoints for the dashboard POST /issues/{id}/close moves a done issue to the Closed column (a plain setIssueStatus; the board's polling SSE feed carries the change). POST /issues/{id}/stop cancels an in-flight run: the run coroutine handle is now kept in $active (was a bare bool), so stop() can cancel() it and drop the issue back to Open. 409 when no run is active. Also: add the run-app skill documenting how to launch the server + UI, and ignore local .claude settings. --- .claude/skills/run-app/SKILL.md | 51 +++++++++++++++++++++++ .gitignore | 3 ++ src/Server.php | 71 +++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/run-app/SKILL.md diff --git a/.claude/skills/run-app/SKILL.md b/.claude/skills/run-app/SKILL.md new file mode 100644 index 0000000..4513f03 --- /dev/null +++ b/.claude/skills/run-app/SKILL.md @@ -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). diff --git a/.gitignore b/.gitignore index c4c37a3..2af51bf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ # Agent tooling (Serena MCP — auto-generated config + cache) /.serena/ + +# Agent tooling (local Claude Code settings) +/.claude/settings.local.json diff --git a/src/Server.php b/src/Server.php index b6187f0..6cb5371 100644 --- a/src/Server.php +++ b/src/Server.php @@ -5,6 +5,7 @@ namespace Claw; use Async\Channel; +use Async\Coroutine; use Async\OperationCanceledException; use function Async\spawn; @@ -57,7 +58,12 @@ final class Server /** Live trace pub/sub: a run's LiveTraceSink publishes here, SSE streams subscribe (push, no poll). */ private TraceBus $bus; - /** @var array issue ids with an active run — guards against a concurrent double-start. */ + /** + * Issue id → the detached run coroutine. Presence guards against a concurrent double-start; the + * handle itself lets {@see stop()} cancel a run in flight. + * + * @var array> + */ private array $active = []; /** @var array> issue id → the open gate's answer channel ({@see answer()} feeds it). */ @@ -136,6 +142,18 @@ public function handle(HttpRequest $request, HttpResponse $response): void return; } + + if (\preg_match('#^/api/projects/([^/]+)/issues/([^/]+)/close$#', $path, $matches)) { + $this->close($response, $matches[1], $matches[2]); + + return; + } + + if (\preg_match('#^/api/projects/([^/]+)/issues/([^/]+)/stop$#', $path, $matches)) { + $this->stop($response, $matches[1], $matches[2]); + + return; + } $response->json(['error' => 'not found', 'path' => $path], 404); return; @@ -571,7 +589,6 @@ private function start(HttpResponse $response, string $key, string $issueId): vo return; } - $this->active[$issue->id] = true; /** @var Channel $answers unbuffered: a gate's send() waits for the parked run's recv() */ $answers = new Channel(); $this->gates[$issue->id] = $answers; @@ -580,7 +597,8 @@ private function start(HttpResponse $response, string $key, string $issueId): vo // Spawn detached so the run survives this handler returning; the dashboard watches the run stream. // The run records its own final status, so there is nothing to catch — only the active/gate cleanup. - spawn(function () use ($store, $config, $agent, $issue, $frontend): void { + // The handle is kept in $active so stop() can cancel the run. + $this->active[$issue->id] = spawn(function () use ($store, $config, $agent, $issue, $frontend): void { try { new IssueRunner($this->projectsDir, $store, $config, $agent, $frontend)->run($issue); } finally { @@ -596,6 +614,53 @@ private function start(HttpResponse $response, string $key, string $issueId): vo * while the issue is WaitingHuman (a gate is actually open); otherwise there is nothing to answer and * the unbuffered send would hang, so we reject with 409. */ + /** + * POST .../issues/{id}/stop — cancel an in-flight run. Cancellation unwinds the run coroutine + * cooperatively (the IssueRunner propagates it); we then drop the issue back to Open so its card + * leaves the in-progress column and can be started again. A 409 if no run is active. + */ + private function stop(HttpResponse $response, string $key, string $issueId): void + { + $coroutine = $this->active[$issueId] ?? null; + + if ($coroutine === null) { + $response->json(['error' => 'no run is active for this issue'], 409); + + return; + } + + try { + $store = $this->storeFor($key); + } catch (\Exception $e) { + $response->json(['error' => $e->getMessage()], 404); + + return; + } + + $coroutine->cancel(); // request cancellation; the run unwinds and the spawn's finally cleans up + $store->setIssueStatus($issueId, IssueStatus::Open); + $response->json(['ok' => true], 202); + } + + /** + * POST .../issues/{id}/close — move an issue to the Closed (archive) column. A plain status write; + * the board's polling SSE feed carries the change to every client on its next tick. + */ + private function close(HttpResponse $response, string $key, string $issueId): void + { + try { + $store = $this->storeFor($key); + $store->loadIssue($issueId); // 404 if the issue does not exist + } catch (\Exception $e) { + $response->json(['error' => $e->getMessage()], 404); + + return; + } + + $store->setIssueStatus($issueId, IssueStatus::Closed); + $response->json(['ok' => true], 202); + } + private function answer(HttpRequest $request, HttpResponse $response, string $key, string $issueId): void { $channel = $this->gates[$issueId] ?? null; From efd5ecd468345cf71f090fa8c4ed2a17830e56fc Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:04:51 +0000 Subject: [PATCH 2/4] feat(trace): tag each artifact with the step that produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit artifactRecords now walks the run tracking open step spans, so every artifact record carries the innermost step name — the dashboard shows which step emitted which artifact. --- src/Trace/TraceReader.php | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Trace/TraceReader.php b/src/Trace/TraceReader.php index c726cda..6f5b8bd 100644 --- a/src/Trace/TraceReader.php +++ b/src/Trace/TraceReader.php @@ -172,13 +172,36 @@ public function tokens(string $runId): array */ public function artifactRecords(string $runId): array { - $stmt = $this->pdo->prepare("SELECT data FROM trace WHERE run_id = ? AND type = 'artifact' ORDER BY seq"); + // Walk the run in order, tracking the open `step` spans so each artifact is tagged with the + // step that produced it (the innermost step still open when the artifact event fired). + $stmt = $this->pdo->prepare('SELECT span_id, phase, type, data FROM trace WHERE run_id = ? ORDER BY seq'); $stmt->execute([$runId]); + $openSteps = []; // span_id => step name; insertion order = nesting, last is innermost $records = []; - foreach ($stmt->fetchAll(\PDO::FETCH_COLUMN) as $json) { - $artifact = json_decode((string) $json, true); + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { + $phase = (string) $row['phase']; + $type = (string) $row['type']; + + if ($phase === 'enter' && $type === 'step') { + $data = json_decode((string) $row['data'], true); + $openSteps[(int) $row['span_id']] = \is_array($data) ? (string) ($data['name'] ?? '') : ''; + + continue; + } + + if ($phase === 'exit') { + unset($openSteps[(int) $row['span_id']]); + + continue; + } + + if ($type !== 'artifact') { + continue; + } + + $artifact = json_decode((string) $row['data'], true); if (!\is_array($artifact)) { continue; @@ -190,6 +213,7 @@ public function artifactRecords(string $runId): array 'ext' => (string) ($artifact['ext'] ?? ''), 'mime' => (string) ($artifact['mime'] ?? ''), 'meta' => '', + 'step' => $openSteps === [] ? '' : (string) end($openSteps), 'body' => (string) ($artifact['value'] ?? ''), ]; } From 46e09190193eb1dd11df213b1827b61852a96ce6 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:23:18 +0000 Subject: [PATCH 3/4] feat(server): soft-delete issues + board removal events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /issues/{id}/delete cancels any in-flight run, then marks the issue Deleted (a new IssueStatus case) — the row, its runs and trace stay in the db but allIssues() hides Deleted, so the board no longer shows it. issuesStream now emits an 'issue-removed' event for an id that vanished from the snapshot set, so clients drop a deleted (or otherwise gone) card live instead of keeping it until reconnect. --- src/Project/IssueStatus.php | 2 ++ src/Project/ProjectStore.php | 4 +++- src/Server.php | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/Project/IssueStatus.php b/src/Project/IssueStatus.php index c3db0ee..a83bf3c 100644 --- a/src/Project/IssueStatus.php +++ b/src/Project/IssueStatus.php @@ -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 diff --git a/src/Project/ProjectStore.php b/src/Project/ProjectStore.php index 5e06c35..16dd449 100644 --- a/src/Project/ProjectStore.php +++ b/src/Project/ProjectStore.php @@ -154,7 +154,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( @@ -323,6 +324,7 @@ public function setIssueStatus(string $issueId, IssueStatus $status): void $stmt->execute(['status' => $status->name, 'id' => $issueId]); } + /** A filesystem-safe, stable key derived from a folder's absolute path. */ public static function keyFor(string $absolutePath): string { diff --git a/src/Server.php b/src/Server.php index 6cb5371..4fe129a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -154,6 +154,12 @@ public function handle(HttpRequest $request, HttpResponse $response): void return; } + + if (\preg_match('#^/api/projects/([^/]+)/issues/([^/]+)/delete$#', $path, $matches)) { + $this->delete($response, $matches[1], $matches[2]); + + return; + } $response->json(['error' => 'not found', 'path' => $path], 404); return; @@ -526,9 +532,11 @@ private function issuesStream(HttpResponse $response, string $key): void try { while (!$response->isClosed()) { $changed = false; + $present = []; foreach ($this->issues($key) as $issue) { $id = (string) $issue['id']; + $present[$id] = true; $snapshot = \json_encode($issue, self::JSON); if (($sentSnapshots[$id] ?? null) === $snapshot) { @@ -543,6 +551,17 @@ private function issuesStream(HttpResponse $response, string $key): void $changed = true; } + // an id we sent before that is gone now = a deleted issue → tell the client to drop it + foreach ($sentSnapshots as $id => $_) { + if (isset($present[$id]) || !$response->sendable()) { + continue; + } + + unset($sentSnapshots[$id]); + $response->sseEvent(data: \json_encode(['id' => (int) $id], self::JSON), event: 'issue-removed'); + $changed = true; + } + if ($changed) { $idleTicks = 0; } elseif (++$idleTicks >= 5) { // ~10s of a still board → heartbeat past proxy idle timeouts @@ -642,6 +661,27 @@ private function stop(HttpResponse $response, string $key, string $issueId): voi $response->json(['ok' => true], 202); } + /** + * POST .../issues/{id}/delete — soft-delete an issue: cancel any in-flight run, then mark it Deleted. + * The row (and its runs/trace) stays in the db, but the board hides it; the SSE feed drops the card. + */ + private function delete(HttpResponse $response, string $key, string $issueId): void + { + try { + $store = $this->storeFor($key); + $store->loadIssue($issueId); // 404 if the issue does not exist + } catch (\Exception $e) { + $response->json(['error' => $e->getMessage()], 404); + + return; + } + + $active = $this->active[$issueId] ?? null; + $active?->cancel(); // stop an in-flight run before hiding the issue + $store->setIssueStatus($issueId, IssueStatus::Deleted); + $response->json(['ok' => true], 202); + } + /** * POST .../issues/{id}/close — move an issue to the Closed (archive) column. A plain status write; * the board's polling SSE feed carries the change to every client on its next tick. From 254d97ee096e45ad6510666a48901e991213a2ba Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:10:20 +0000 Subject: [PATCH 4/4] refactor(project,server): pool the project store, retire the read/write handle split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Server hand-rolled connection lifecycle — a cached read handle plus a fresh storeFor() per run — to keep a run's writes off the shared connection. That put a database concern in the HTTP layer and leaked it as two ways to get a store, one of which createIssue picked wrong. TrueAsync's PDO pool already provides exactly this: it hands each coroutine its own connection and keeps it pinned across awaits, so one shared handle is safe for concurrent reads and writes and INSERT + lastInsertId() stays correct. - ProjectStore::open() enables the pool (ATTR_POOL_*) + WAL; busy timeout moves to ATTR_TIMEOUT so it applies to every pooled connection, not just the first - collapse Server readStore()/storeFor()/$readStores into one cached store() - extract ProjectStoreInterface; runner and frontends depend on it, not the concrete store - createIssue: 404 for an unknown project (was an uncaught 500); status read from the issue - move a docblock that had drifted onto stop() back onto answer(); drop a stray blank line - tests: concurrent-coroutine pool safety; Artifact content-type sniffing --- src/HttpGateSpeaker.php | 4 +- src/Project/ProjectStore.php | 37 ++++++++++---- src/Project/ProjectStoreInterface.php | 69 +++++++++++++++++++++++++ src/Run/HttpRunFrontend.php | 4 +- src/Run/IssueRunner.php | 4 +- src/Run/RunContext.php | 4 +- src/Server.php | 73 +++++++++++++++------------ tests/Project/ProjectStoreTest.php | 46 +++++++++++++++++ tests/Workflow/ArtifactTest.php | 49 ++++++++++++++++++ 9 files changed, 241 insertions(+), 49 deletions(-) create mode 100644 src/Project/ProjectStoreInterface.php create mode 100644 tests/Workflow/ArtifactTest.php diff --git a/src/HttpGateSpeaker.php b/src/HttpGateSpeaker.php index 3cb3400..980816e 100644 --- a/src/HttpGateSpeaker.php +++ b/src/HttpGateSpeaker.php @@ -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; /** @@ -28,7 +28,7 @@ /** @param Channel $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, ) { diff --git a/src/Project/ProjectStore.php b/src/Project/ProjectStore.php index 16dd449..40ff354 100644 --- a/src/Project/ProjectStore.php +++ b/src/Project/ProjectStore.php @@ -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, @@ -324,7 +325,6 @@ public function setIssueStatus(string $issueId, IssueStatus $status): void $stmt->execute(['status' => $status->name, 'id' => $issueId]); } - /** A filesystem-safe, stable key derived from a folder's absolute path. */ public static function keyFor(string $absolutePath): string { @@ -360,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; } diff --git a/src/Project/ProjectStoreInterface.php b/src/Project/ProjectStoreInterface.php new file mode 100644 index 0000000..30af658 --- /dev/null +++ b/src/Project/ProjectStoreInterface.php @@ -0,0 +1,69 @@ + + */ + public function allIssues(): array; + + /** + * The runs spawned for an issue, oldest first, with their status. + * + * @return list + */ + 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 + */ + 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; +} diff --git a/src/Run/HttpRunFrontend.php b/src/Run/HttpRunFrontend.php index 2590e3b..bc0c3e3 100644 --- a/src/Run/HttpRunFrontend.php +++ b/src/Run/HttpRunFrontend.php @@ -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; @@ -23,7 +23,7 @@ { /** @param Channel $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, diff --git a/src/Run/IssueRunner.php b/src/Run/IssueRunner.php index 926e4c0..88374bf 100644 --- a/src/Run/IssueRunner.php +++ b/src/Run/IssueRunner.php @@ -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; @@ -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, diff --git a/src/Run/RunContext.php b/src/Run/RunContext.php index d3e04ea..b345c0b 100644 --- a/src/Run/RunContext.php +++ b/src/Run/RunContext.php @@ -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; @@ -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, diff --git a/src/Server.php b/src/Server.php index 4fe129a..1afdec9 100644 --- a/src/Server.php +++ b/src/Server.php @@ -16,6 +16,7 @@ use Claw\Project\IssueStatus; use Claw\Project\Project; use Claw\Project\ProjectStore; +use Claw\Project\ProjectStoreInterface; use Claw\Run\HttpRunFrontend; use Claw\Run\IssueRunner; use Claw\Trace\TraceBus; @@ -69,8 +70,14 @@ final class Server /** @var array> issue id → the open gate's answer channel ({@see answer()} feeds it). */ private array $gates = []; - /** @var array read handles, one per project key, opened once and reused. */ - private array $readStores = []; + /** + * One pooled store handle per project key, opened once and reused. The handle's \PDO has TrueAsync's + * connection pool enabled, so the SAME handle is shared by the dashboard's reads and a detached run's + * writes — the pool hands each coroutine its own connection. There is no read/write split to manage. + * + * @var array + */ + private array $stores = []; /** @var array trace readers over the read handles, cached so a stream re-uses one. */ private array $readers = []; @@ -268,7 +275,7 @@ private function createProject(HttpRequest $request, HttpResponse $response): vo } // Drop any cached miss so the new project is readable on the next request. - unset($this->readStores[$project->id], $this->readers[$project->id]); + unset($this->stores[$project->id], $this->readers[$project->id]); $response->json(['key' => $project->id, 'name' => $project->name, 'path' => $project->path], 201); } @@ -285,14 +292,22 @@ private function createIssue(HttpRequest $request, HttpResponse $response, strin $description = \is_array($payload) && isset($payload['description']) ? (string) $payload['description'] : ''; try { - $issue = $this->readStore($key)->addIssue($title, $description); + $store = $this->store($key); + } catch (\Exception $e) { + $response->json(['error' => $e->getMessage()], 404); + + return; + } + + try { + $issue = $store->addIssue($title, $description); } catch (ClawException $e) { $response->json(['error' => $e->getMessage()], 400); return; } - $response->json(['id' => (int) $issue->id, 'title' => $issue->title, 'status' => IssueStatus::Open->value], 201); + $response->json(['id' => (int) $issue->id, 'title' => $issue->title, 'status' => $issue->status->value], 201); } /** @@ -303,7 +318,7 @@ private function createIssue(HttpRequest $request, HttpResponse $response, strin */ private function issues(string $key): array { - $store = $this->readStore($key); + $store = $this->store($key); $reader = $this->reader($key); $state = new SqliteStateStore($store->pdo()); @@ -348,17 +363,22 @@ private function issues(string $key): array return $issues; } - /** A reused read handle for a project (the dashboard only SELECTs); opened once, cached by key. */ - private function readStore(string $key): ProjectStore + /** + * The one pooled store handle for a project, opened once and cached by key. Used for reads AND + * writes from any coroutine — TrueAsync's PDO pool gives each its own connection, so there is no + * need for a fresh handle per run. An unknown project key is a {@see \RuntimeException} (a 404 at + * the call site). + */ + private function store(string $key): ProjectStoreInterface { - return $this->readStores[$key] ??= ProjectStore::openByKey($this->projectsDir, $key) + return $this->stores[$key] ??= ProjectStore::openByKey($this->projectsDir, $key) ?? throw new \RuntimeException("unknown project: {$key}"); } /** The trace reader over a project's read handle, cached so a stream does not re-open it per tail. */ private function reader(string $key): TraceReader { - return $this->readers[$key] ??= new TraceReader($this->readStore($key)->pdo()); + return $this->readers[$key] ??= new TraceReader($this->store($key)->pdo()); } /** @@ -375,7 +395,7 @@ private function artifactFile(HttpResponse $response, string $key, string $relat return; } - $root = realpath($this->readStore($key)->project()->path); + $root = realpath($this->store($key)->project()->path); $full = realpath($root . '/' . $relative); // realpath resolves `..`, so containment is a clean prefix check on the canonical paths. @@ -517,7 +537,7 @@ private function liveRow(TraceRecordInterface $record, int $seq): array private function issuesStream(HttpResponse $response, string $key): void { try { - $this->readStore($key); // resolve/validate the project before committing the stream + $this->store($key); // resolve/validate the project before committing the stream } catch (\Exception $e) { $response->json(['error' => $e->getMessage()], 404); @@ -585,7 +605,7 @@ private function issuesStream(HttpResponse $response, string $key): void private function start(HttpResponse $response, string $key, string $issueId): void { try { - $store = $this->storeFor($key); + $store = $this->store($key); $issue = $store->loadIssue($issueId); } catch (\Exception $e) { $response->json(['error' => $e->getMessage()], 404); @@ -628,11 +648,6 @@ private function start(HttpResponse $response, string $key, string $issueId): vo $response->json(['ok' => true], 202); } - /** - * POST .../issues/{id}/answer — deliver the human's reply to the run parked at its gate. Valid only - * while the issue is WaitingHuman (a gate is actually open); otherwise there is nothing to answer and - * the unbuffered send would hang, so we reject with 409. - */ /** * POST .../issues/{id}/stop — cancel an in-flight run. Cancellation unwinds the run coroutine * cooperatively (the IssueRunner propagates it); we then drop the issue back to Open so its card @@ -649,7 +664,7 @@ private function stop(HttpResponse $response, string $key, string $issueId): voi } try { - $store = $this->storeFor($key); + $store = $this->store($key); } catch (\Exception $e) { $response->json(['error' => $e->getMessage()], 404); @@ -668,7 +683,7 @@ private function stop(HttpResponse $response, string $key, string $issueId): voi private function delete(HttpResponse $response, string $key, string $issueId): void { try { - $store = $this->storeFor($key); + $store = $this->store($key); $store->loadIssue($issueId); // 404 if the issue does not exist } catch (\Exception $e) { $response->json(['error' => $e->getMessage()], 404); @@ -689,7 +704,7 @@ private function delete(HttpResponse $response, string $key, string $issueId): v private function close(HttpResponse $response, string $key, string $issueId): void { try { - $store = $this->storeFor($key); + $store = $this->store($key); $store->loadIssue($issueId); // 404 if the issue does not exist } catch (\Exception $e) { $response->json(['error' => $e->getMessage()], 404); @@ -701,6 +716,11 @@ private function close(HttpResponse $response, string $key, string $issueId): vo $response->json(['ok' => true], 202); } + /** + * POST .../issues/{id}/answer — deliver the human's reply to the run parked at its gate. Valid only + * while the issue is WaitingHuman (a gate is actually open); otherwise there is nothing to answer and + * the unbuffered send would hang, so we reject with 409. + */ private function answer(HttpRequest $request, HttpResponse $response, string $key, string $issueId): void { $channel = $this->gates[$issueId] ?? null; @@ -712,7 +732,7 @@ private function answer(HttpRequest $request, HttpResponse $response, string $ke } try { - $issue = $this->readStore($key)->loadIssue($issueId); + $issue = $this->store($key)->loadIssue($issueId); } catch (\Exception $e) { $response->json(['error' => $e->getMessage()], 404); @@ -732,13 +752,4 @@ private function answer(HttpRequest $request, HttpResponse $response, string $ke $response->json(['ok' => true], 202); } - /** - * Open a FRESH writable handle for a run (its own connection — a run mutates state across awaits, so - * it must not share the cached read handle). Not cached: each run gets its own. - */ - private function storeFor(string $key): ProjectStore - { - return ProjectStore::openByKey($this->projectsDir, $key) - ?? throw new \RuntimeException("unknown project: {$key}"); - } } diff --git a/tests/Project/ProjectStoreTest.php b/tests/Project/ProjectStoreTest.php index cf85345..6c200a7 100644 --- a/tests/Project/ProjectStoreTest.php +++ b/tests/Project/ProjectStoreTest.php @@ -219,6 +219,52 @@ public function recentRunsListsTheLedgerNewestFirst(): void } } + #[Test] + public function onePooledHandleIsSafeAcrossConcurrentCoroutines(): void + { + $projectsDir = self::tempDir(); + $folder = self::tempDir(); + + try { + $store = self::openProject($projectsDir, $folder); + + // One shared handle, sixteen coroutines writing at once. TrueAsync's PDO pool hands each + // coroutine its own connection, so the INSERT + lastInsertId() inside addIssue()/recordRun() + // must stay correct under interleaving — this is the guarantee that retired the per-run + // `storeFor()` handle. The delay forces a yield between the two inserts to stress it. + $coros = []; + + for ($i = 0; $i < 16; $i++) { + $coros[] = \Async\spawn(static function () use ($store, $i): array { + $issue = $store->addIssue("issue {$i}", "body {$i}"); + \Async\delay(1); + $runId = $store->recordRun($issue->id, "Solver{$i}"); + + return ['title' => "issue {$i}", 'issueId' => $issue->id, 'runId' => $runId]; + }); + } + + $results = array_map(static fn ($c): array => \Async\await($c), $coros); + + $issueIds = array_map(static fn (array $r): string => $r['issueId'], $results); + $runIds = array_map(static fn (array $r): string => $r['runId'], $results); + + // distinct ids: lastInsertId() never returned another coroutine's row + Assert::same(count(array_unique($issueIds)), 16); + Assert::same(count(array_unique($runIds)), 16); + + // each id resolves back to exactly that coroutine's own title (no cross-wiring) + foreach ($results as $r) { + Assert::same($store->loadIssue($r['issueId'])->title, $r['title']); + } + + Assert::count($store->allIssues(), 16); + } finally { + self::rmrf($projectsDir); + self::rmrf($folder); + } + } + /** Register a project and return an open handle to it (init + discover). */ private static function openProject(string $projectsDir, string $folder): ProjectStore { diff --git a/tests/Workflow/ArtifactTest.php b/tests/Workflow/ArtifactTest.php new file mode 100644 index 0000000..8ebb7bd --- /dev/null +++ b/tests/Workflow/ArtifactTest.php @@ -0,0 +1,49 @@ +kind, 'file'); + Assert::same($a->ext, 'php'); + Assert::same($a->mime, 'text/x-php'); + } + + #[Test] + public function textSniffsItsContentTypeWhenNoneIsDeclared(): void + { + Assert::same(Artifact::text('p', 'ext, 'php'); + Assert::same(Artifact::text('d', "diff --git a/x b/x\n@@ -1 +1 @@")->ext, 'diff'); + Assert::same(Artifact::text('j', '{"a":1}')->ext, 'json'); + Assert::same(Artifact::text('t', 'just some prose')->ext, 'txt'); + } + + #[Test] + public function textHonoursAnExplicitExtOverTheSniff(): void + { + $a = Artifact::text('x', 'just some prose', 'md'); // would sniff as txt + + Assert::same($a->ext, 'md'); + Assert::same($a->mime, 'text/markdown'); + } + + #[Test] + public function anUnknownExtKeepsTheExtButFallsBackToPlainTextMime(): void + { + $a = Artifact::text('x', 'data', '.XYZ'); // also normalizes the leading dot + case + + Assert::same($a->ext, 'xyz'); + Assert::same($a->mime, 'text/plain'); + } +}