Skip to content

Commit 9839e81

Browse files
authored
Merge pull request #497 from jamosaur/eloquent-persistence
Add Eloquent persistence driver
2 parents 2f1d48c + 722ff44 commit 9839e81

File tree

2 files changed

+318
-0
lines changed

2 files changed

+318
-0
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace NeuronAI\Workflow\Persistence;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
use NeuronAI\Exceptions\WorkflowException;
9+
use NeuronAI\Workflow\Interrupt\WorkflowInterrupt;
10+
11+
use function serialize;
12+
use function unserialize;
13+
14+
class EloquentPersistence implements PersistenceInterface
15+
{
16+
public function __construct(protected string $modelClass)
17+
{
18+
}
19+
20+
public function save(string $workflowId, WorkflowInterrupt $interrupt): void
21+
{
22+
/** @var Model $model */
23+
$model = new $this->modelClass();
24+
25+
$model->newQuery()->updateOrCreate([
26+
'workflow_id' => $workflowId,
27+
], [
28+
'interrupt' => serialize($interrupt),
29+
]);
30+
}
31+
32+
public function load(string $workflowId): WorkflowInterrupt
33+
{
34+
/** @var Model $model */
35+
$model = new $this->modelClass();
36+
37+
$record = $model->newQuery()
38+
->where('workflow_id', $workflowId)
39+
->firstOr(['interrupt'], fn () => throw new WorkflowException("No saved workflow found for ID: {$workflowId}."));
40+
41+
return unserialize($record->interrupt);
42+
}
43+
44+
public function delete(string $workflowId): void
45+
{
46+
/** @var Model $model */
47+
$model = new $this->modelClass();
48+
49+
$model->newQuery()
50+
->where('workflow_id', $workflowId)
51+
->delete();
52+
}
53+
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace NeuronAI\Tests\Workflow\Persistence;
6+
7+
use Illuminate\Database\Capsule\Manager;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Schema\Blueprint;
10+
use NeuronAI\Exceptions\WorkflowException;
11+
use NeuronAI\Workflow\Interrupt\Action;
12+
use NeuronAI\Workflow\Interrupt\ApprovalRequest;
13+
use NeuronAI\Workflow\Interrupt\WorkflowInterrupt;
14+
use NeuronAI\Workflow\Persistence\EloquentPersistence;
15+
use NeuronAI\Workflow\Persistence\PersistenceInterface;
16+
use NeuronAI\Workflow\WorkflowState;
17+
use PHPUnit\Framework\TestCase;
18+
19+
use function uniqid;
20+
21+
class EloquentPersistenceTest extends TestCase
22+
{
23+
protected string $workflowId;
24+
25+
protected PersistenceInterface $persistence;
26+
27+
protected function setUp(): void
28+
{
29+
$manager = new Manager();
30+
$manager->addConnection([
31+
'driver' => 'sqlite',
32+
'database' => ':memory:',
33+
]);
34+
$manager->setAsGlobal();
35+
$manager->bootEloquent();
36+
37+
Manager::schema()->create('workflow_interrupts', function (Blueprint $table) {
38+
$table->id();
39+
$table->string('workflow_id')->unique();
40+
$table->longText('interrupt')->charset('binary');
41+
$table->timestamps();
42+
});
43+
44+
$this->workflowId = uniqid('test-thread-');
45+
$this->persistence = new EloquentPersistence(WorkflowInterruptModel::class);
46+
}
47+
48+
protected function tearDown(): void
49+
{
50+
Manager::schema()->dropIfExists('workflow_interrupts');
51+
}
52+
53+
public function testSaveAndLoadWorkflowInterrupt(): void
54+
{
55+
$interrupt = $this->createTestInterrupt();
56+
57+
// Save the interrupt
58+
$this->persistence->save($this->workflowId, $interrupt);
59+
60+
// Load it back
61+
$loadedInterrupt = $this->persistence->load($this->workflowId);
62+
63+
// Verify the data matches
64+
$this->assertInstanceOf(WorkflowInterrupt::class, $loadedInterrupt);
65+
$this->assertEquals($interrupt->getMessage(), $loadedInterrupt->getMessage());
66+
$this->assertEquals(
67+
$interrupt->getRequest()->getMessage(),
68+
$loadedInterrupt->getRequest()->getMessage()
69+
);
70+
/** @var ApprovalRequest $loadedRequest */
71+
$loadedRequest = $loadedInterrupt->getRequest();
72+
$this->assertCount(2, $loadedRequest->getActions());
73+
}
74+
75+
public function testUpdateExistingWorkflowInterrupt(): void
76+
{
77+
// Save the first interrupt
78+
$this->persistence->save($this->workflowId, $this->createTestInterrupt('First message'));
79+
80+
// Update with new interrupt
81+
$this->persistence->save($this->workflowId, $this->createTestInterrupt('Second message'));
82+
83+
// Load and verify it was updated
84+
$loadedInterrupt = $this->persistence->load($this->workflowId);
85+
86+
$this->assertEquals('Second message', $loadedInterrupt->getRequest()->getMessage());
87+
}
88+
89+
public function testDeleteWorkflowInterrupt(): void
90+
{
91+
$interrupt = $this->createTestInterrupt();
92+
93+
// Save and verify it exists
94+
$this->persistence->save($this->workflowId, $interrupt);
95+
$this->assertInstanceOf(WorkflowInterrupt::class, $this->persistence->load($this->workflowId));
96+
97+
// Delete it
98+
$this->persistence->delete($this->workflowId);
99+
100+
// Verify it throws an exception when trying to load deleted workflow
101+
$this->expectException(WorkflowException::class);
102+
$this->expectExceptionMessage("No saved workflow found for ID: {$this->workflowId}");
103+
$this->persistence->load($this->workflowId);
104+
}
105+
106+
public function testLoadNonExistentWorkflowThrowsException(): void
107+
{
108+
$this->expectException(WorkflowException::class);
109+
$this->expectExceptionMessage("No saved workflow found for ID: {$this->workflowId}");
110+
111+
$this->persistence->load($this->workflowId);
112+
}
113+
114+
public function testSavePreservesAllInterruptData(): void
115+
{
116+
$state = new WorkflowState([
117+
'key1' => 'value1',
118+
'key2' => 42,
119+
'key3' => ['nested' => 'array'],
120+
'__workflowId' => $this->workflowId,
121+
]);
122+
123+
$interrupt = $this->createTestInterruptWithState($state);
124+
125+
$this->persistence->save($this->workflowId, $interrupt);
126+
$loadedInterrupt = $this->persistence->load($this->workflowId);
127+
128+
// Verify state was preserved
129+
$loadedState = $loadedInterrupt->getState();
130+
$this->assertEquals('value1', $loadedState->get('key1'));
131+
$this->assertEquals(42, $loadedState->get('key2'));
132+
$this->assertEquals(['nested' => 'array'], $loadedState->get('key3'));
133+
134+
// Verify actions were preserved
135+
/** @var ApprovalRequest $loadedRequest */
136+
$loadedRequest = $loadedInterrupt->getRequest();
137+
$actions = $loadedRequest->getActions();
138+
$this->assertCount(2, $actions);
139+
$this->assertEquals('action_1', $actions[0]->id);
140+
$this->assertEquals('Execute Command', $actions[0]->name);
141+
}
142+
143+
public function testSaveAndLoadWithApprovedActions(): void
144+
{
145+
$interrupt = $this->createTestInterrupt();
146+
147+
// Approve one action
148+
/** @var ApprovalRequest $request */
149+
$request = $interrupt->getRequest();
150+
$actions = $request->getActions();
151+
$actions[0]->approve('Looks good');
152+
153+
$this->persistence->save($this->workflowId, $interrupt);
154+
$loadedInterrupt = $this->persistence->load($this->workflowId);
155+
156+
// Verify action decisions were preserved
157+
/** @var ApprovalRequest $loadedRequest */
158+
$loadedRequest = $loadedInterrupt->getRequest();
159+
$loadedActions = $loadedRequest->getActions();
160+
$this->assertTrue($loadedActions[0]->isApproved());
161+
$this->assertEquals('Looks good', $loadedActions[0]->feedback);
162+
$this->assertTrue($loadedActions[1]->isPending());
163+
}
164+
165+
public function testSaveAndLoadWithRejectedActions(): void
166+
{
167+
$interrupt = $this->createTestInterrupt();
168+
169+
// Reject one action
170+
/** @var ApprovalRequest $request */
171+
$request = $interrupt->getRequest();
172+
$actions = $request->getActions();
173+
$actions[1]->reject('Too dangerous');
174+
175+
$this->persistence->save($this->workflowId, $interrupt);
176+
$loadedInterrupt = $this->persistence->load($this->workflowId);
177+
178+
// Verify action decisions were preserved
179+
/** @var ApprovalRequest $loadedRequest */
180+
$loadedRequest = $loadedInterrupt->getRequest();
181+
$loadedActions = $loadedRequest->getActions();
182+
$this->assertTrue($loadedActions[1]->isRejected());
183+
$this->assertEquals('Too dangerous', $loadedActions[1]->feedback);
184+
}
185+
186+
public function testMultipleWorkflowsAreIndependent(): void
187+
{
188+
$workflowId1 = $this->workflowId . '-workflow-1';
189+
$workflowId2 = $this->workflowId . '-workflow-2';
190+
191+
$interrupt1 = $this->createTestInterrupt('Workflow 1 message', $workflowId1);
192+
$interrupt2 = $this->createTestInterrupt('Workflow 2 message', $workflowId2);
193+
194+
// Save both
195+
$this->persistence->save($workflowId1, $interrupt1);
196+
$this->persistence->save($workflowId2, $interrupt2);
197+
198+
// Load and verify they're independent
199+
$loaded1 = $this->persistence->load($workflowId1);
200+
$loaded2 = $this->persistence->load($workflowId2);
201+
202+
$this->assertEquals('Workflow 1 message', $loaded1->getRequest()->getMessage());
203+
$this->assertEquals('Workflow 2 message', $loaded2->getRequest()->getMessage());
204+
205+
// Delete one shouldn't affect the other
206+
$this->persistence->delete($workflowId1);
207+
208+
$this->expectException(WorkflowException::class);
209+
$this->persistence->load($workflowId1);
210+
211+
// Workflow 2 should still exist
212+
$loaded2Again = $this->persistence->load($workflowId2);
213+
$this->assertEquals('Workflow 2 message', $loaded2Again->getRequest()->getMessage());
214+
}
215+
216+
/**
217+
* Helper method to create a test WorkflowInterrupt.
218+
*/
219+
private function createTestInterrupt(string $message = 'Test interrupt message', ?string $id = null): \NeuronAI\Workflow\Interrupt\WorkflowInterrupt
220+
{
221+
$state = new WorkflowState(['test_key' => 'test_value', '__workflowId' => $id ?? $this->workflowId]);
222+
return $this->createTestInterruptWithState($state, $message);
223+
}
224+
225+
/**
226+
* Helper method to create a test WorkflowInterrupt with specific state.
227+
*/
228+
private function createTestInterruptWithState(
229+
WorkflowState $state,
230+
string $message = 'Test interrupt message'
231+
): WorkflowInterrupt {
232+
$request = new ApprovalRequest(
233+
$message,
234+
[
235+
new Action(
236+
'action_1',
237+
'Execute Command',
238+
'Execute a potentially dangerous command'
239+
),
240+
new Action(
241+
'action_2',
242+
'Delete File',
243+
'Delete an important file'
244+
),
245+
]
246+
);
247+
248+
$node = new \NeuronAI\Tests\Workflow\Stubs\InterruptableNode();
249+
$event = new \NeuronAI\Workflow\Events\StartEvent();
250+
251+
return new WorkflowInterrupt($request, $node, $state, $event);
252+
}
253+
}
254+
255+
/**
256+
* Mock Eloquent Model for testing
257+
*
258+
* @property string $workflow_id
259+
* @property string $interrupt
260+
*/
261+
class WorkflowInterruptModel extends Model
262+
{
263+
protected $table = 'workflow_interrupts';
264+
protected $fillable = ['workflow_id', 'interrupt'];
265+
}

0 commit comments

Comments
 (0)