From f889b363d6b9f44398ed2f135a9006981fecff8d Mon Sep 17 00:00:00 2001 From: jordan Date: Tue, 18 Aug 2026 22:54:41 -0700 Subject: [PATCH] feat: POST /api/assign is the Asgard factory door No LLM. Writes a queued board row and enqueues DispatchKitWork. Asgard kit_assign and Lexi AskKit hit this, not /api/ask. --- README.md | 4 +- app/Http/Controllers/AssignController.php | 91 ++++++++++++++--------- app/Jobs/DispatchKitWork.php | 49 ++++++++++++ tests/Feature/AssignTest.php | 67 ++++++++++++----- 4 files changed, 154 insertions(+), 57 deletions(-) create mode 100644 app/Jobs/DispatchKitWork.php diff --git a/README.md b/README.md index 052c3b5..e41c558 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ SDK: **[laravel/ai](https://laravel.com/docs/ai-sdk)** (`BaseAgent` + tools). We ``` GET /health -POST /api/ask Bearer KIT_PEER_TOKEN {"message":"..."} -POST /api/assign Bearer KIT_PEER_TOKEN {"issue":"...","chair":"kit","brief":"..."} +POST /api/ask Bearer KIT_PEER_TOKEN {"message":"..."} → Kit LLM (mouth) +POST /api/assign Bearer KIT_PEER_TOKEN {issue?,chair?,brief?,kind?} → board + queue, **no LLM** POST /api/webhooks/mattermost token=KIT_WEBHOOK_TOKEN → queues ReplyOnMattermost php artisan queue:work --queue=kit php artisan kit:ask "what's in the catalog?" diff --git a/app/Http/Controllers/AssignController.php b/app/Http/Controllers/AssignController.php index 8201386..9647104 100644 --- a/app/Http/Controllers/AssignController.php +++ b/app/Http/Controllers/AssignController.php @@ -3,13 +3,19 @@ namespace App\Http\Controllers; use App\Factory\Board; -use App\Mattermost\Client; +use App\Jobs\DispatchKitWork; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +/** + * Asgard / Lexi factory door. No LLM. Enqueue + board row. + * + * POST /api/assign Bearer KIT_PEER_TOKEN + * { issue?, chair?, brief?, kind? } + */ class AssignController extends Controller { - public function __invoke(Request $request, Board $board, Client $mattermost): JsonResponse + public function __invoke(Request $request, Board $board): JsonResponse { $expected = (string) config('kit.peer_token'); $got = (string) $request->bearerToken(); @@ -18,71 +24,84 @@ public function __invoke(Request $request, Board $board, Client $mattermost): Js } $issue = trim((string) $request->input('issue', '')); - if ($issue === '') { - return response()->json(['error' => 'issue required'], 422); - } - + $brief = trim((string) $request->input('brief', '')); + $kind = strtolower(trim((string) $request->input('kind', ''))); $chair = strtolower(trim((string) $request->input('chair', 'kit'))); - if (! in_array($chair, ['kit', 'feel', 'bench'], true)) { + + if (! in_array($chair, ['kit', 'bench', 'feel'], true)) { $chair = 'kit'; } - $number = $this->issueNumber($issue); - $id = $this->boardId($issue, $number, (string) $request->input('brief', '')); - $brief = trim((string) $request->input('brief', '')); + $issueNumber = $this->issueNumber($issue); + if ($issue === '' && $brief === '' && $kind === '') { + return response()->json(['error' => 'issue, brief, or kind required'], 422); + } - $item = $board->upsert($id, [ - 'state' => 'queued', + $boardId = $this->boardId($issueNumber, $kind, $brief, $issue); + $state = $kind !== '' ? $kind : 'queued'; + $note = $brief !== '' ? $brief : ($issue !== '' ? $issue : $kind); + + $fields = [ + 'state' => $state, 'lifecycle' => 'queued', 'owner' => $chair, - 'issue' => $number, - 'hops' => 0, - 'note' => $brief !== '' ? $brief : 'assigned '.$issue, - ]); + 'note' => $note, + ]; + if ($issueNumber !== null) { + $fields['issue'] = $issueNumber; + } - $this->hallwayAck($mattermost, $id, $issue, $chair); + $item = $board->upsert($boardId, $fields); + + DispatchKitWork::dispatch( + boardId: $boardId, + chair: $chair, + brief: $note, + issue: $issue, + kind: $kind, + ); return response()->json([ 'ok' => true, - 'board_id' => $id, + 'board_id' => $boardId, + 'chair' => $chair, 'item' => $item, - ]); + ], 202); } - private function issueNumber(string $issue): int + private function issueNumber(string $issue): ?int { + if ($issue === '') { + return null; + } if (preg_match('/#(\d+)/', $issue, $m) === 1) { return (int) $m[1]; } if (preg_match('/\/issues\/(\d+)/', $issue, $m) === 1) { return (int) $m[1]; } + if (ctype_digit($issue)) { + return (int) $issue; + } - return (int) $issue; + return null; } - private function boardId(string $issue, int $number, string $brief): string + private function boardId(?int $issueNumber, string $kind, string $brief, string $issue): string { - $hay = strtolower($issue.' '.$brief); + $hay = strtolower($issue.' '.$brief.' '.$kind); foreach (['rider', 'hero-ebike', 'ranch-7620'] as $catalog) { if (str_contains($hay, $catalog)) { return $catalog; } } - - return $number > 0 ? 'issue-'.$number : 'assign'; - } - - private function hallwayAck(Client $mattermost, string $id, string $issue, string $chair): void - { - $hallway = (string) config('kit.mattermost.hallway_id'); - $dm = (string) config('kit.mattermost.dm_id'); - if ($hallway === '' || $hallway === $dm) { - return; + if ($issueNumber !== null) { + return 'issue-'.$issueNumber; + } + if ($kind !== '') { + return $kind; } - $short = $number = $this->issueNumber($issue); - $ref = $short > 0 ? '#'.$short : $issue; - $mattermost->post($hallway, 'queued '.$id.' ← '.$ref.' ('.$chair.')'); + return 'assign-'.substr(sha1($brief), 0, 8); } } diff --git a/app/Jobs/DispatchKitWork.php b/app/Jobs/DispatchKitWork.php new file mode 100644 index 0000000..8423bb3 --- /dev/null +++ b/app/Jobs/DispatchKitWork.php @@ -0,0 +1,49 @@ +onQueue('kit'); + } + + public function handle(Client $mm): void + { + $hallway = (string) config('kit.mattermost.hallway_id'); + $bits = ['_asgard assign_']; + $bits[] = 'board=`'.$this->boardId.'`'; + $bits[] = 'chair='.$this->chair; + if ($this->kind !== '') { + $bits[] = 'kind='.$this->kind; + } + if ($this->issue !== '') { + $bits[] = $this->issue; + } + if ($this->brief !== '') { + $bits[] = $this->brief; + } + + $dm = (string) config('kit.mattermost.dm_id'); + if ($hallway === '' || $hallway === $dm) { + return; + } + + $mm->post($hallway, implode(' · ', $bits)); + } +} diff --git a/tests/Feature/AssignTest.php b/tests/Feature/AssignTest.php index cc1a051..0a0a214 100644 --- a/tests/Feature/AssignTest.php +++ b/tests/Feature/AssignTest.php @@ -1,13 +1,18 @@ $path, 'kit.peer_token' => 'test-peer', + 'kit.mattermost.url' => 'http://mm.test', + 'kit.mattermost.token' => 'mm-tok', 'kit.mattermost.hallway_id' => 'hall-1', 'kit.mattermost.dm_id' => 'dm-1', ]); @@ -18,21 +23,18 @@ @unlink($this->boardPath); }); -test('assign refuses a bad bearer', function () { - $this->postJson('/api/assign', ['issue' => 'https://github.com/the-shit/bikes-v2/issues/12']) - ->assertUnauthorized(); +it('rejects assign without bearer', function () { + $this->postJson('/api/assign', ['brief' => 'make a flyer'])->assertUnauthorized(); }); -test('assign requires an issue', function () { +it('rejects empty assign', function () { $this->withToken('test-peer') - ->postJson('/api/assign', ['brief' => 'no issue']) + ->postJson('/api/assign', []) ->assertStatus(422); }); -test('assign queues a board row and does not speak as the mouth', function () { - $mm = Mockery::mock(Client::class); - $mm->shouldReceive('post')->once()->with('hall-1', 'queued rider ← #12 (kit)'); - $this->app->instance(Client::class, $mm); +it('queues work and writes a board row without starting the Kit LLM', function () { + Queue::fake(); $this->withToken('test-peer') ->postJson('/api/assign', [ @@ -40,27 +42,54 @@ 'chair' => 'kit', 'brief' => 'rider stills', ]) - ->assertOk() + ->assertStatus(202) ->assertJsonPath('ok', true) ->assertJsonPath('board_id', 'rider'); + Queue::assertPushed(DispatchKitWork::class, function (DispatchKitWork $job): bool { + return $job->boardId === 'rider' && $job->chair === 'kit'; + }); + $row = collect(app(Board::class)->read()['items'])->firstWhere('id', 'rider'); expect($row['lifecycle'])->toBe('queued') ->and($row['owner'])->toBe('kit') ->and($row['issue'])->toBe(12); }); -test('assign skips mattermost when hallway is the Jordan DM', function () { - config(['kit.mattermost.hallway_id' => 'dm-1']); - $mm = Mockery::mock(Client::class); - $mm->shouldReceive('post')->never(); - $this->app->instance(Client::class, $mm); +it('accepts make_image without a github issue', function () { + Queue::fake(); $this->withToken('test-peer') ->postJson('/api/assign', [ - 'issue' => 'https://github.com/the-shit/kit/issues/5', + 'kind' => 'make_image', 'chair' => 'kit', + 'brief' => 'Lexi yoga still', ]) - ->assertOk() - ->assertJsonPath('board_id', 'issue-5'); + ->assertStatus(202) + ->assertJsonPath('board_id', 'make_image'); +}); + +it('posts the hallway receipt when the job runs', function () { + Http::fake([ + 'http://mm.test/api/v4/posts' => Http::response(['id' => 'p1'], 201), + ]); + + (new DispatchKitWork('issue-12', 'kit', 'rider stills', 'https://github.com/the-shit/bikes-v2/issues/12', '')) + ->handle(app(Client::class)); + + Http::assertSent(function ($req): bool { + return $req->url() === 'http://mm.test/api/v4/posts' + && str_contains((string) $req['message'], 'issue-12') + && str_contains((string) $req['message'], '_asgard assign_'); + }); +}); + +it('skips mattermost when hallway is the Jordan DM', function () { + config(['kit.mattermost.hallway_id' => 'dm-1']); + Http::fake(); + + (new DispatchKitWork('issue-5', 'kit', 'x', 'https://github.com/the-shit/kit/issues/5', '')) + ->handle(app(Client::class)); + + Http::assertNothingSent(); });