Skip to content

Commit 42e1190

Browse files
authored
Merge pull request #303 from durable-workflow/feat/sdk-php-3.9-friendly-durations
Adopt PHP SDK 3.9 friendly durations and add a service-mode conversation
2 parents 3f6741a + a7a46e5 commit 42e1190

24 files changed

Lines changed: 1889 additions & 130 deletions

‎README.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ all three runtimes and the artifact versions used for the run.
4242
The qualified tuple in
4343
[`polyglot/qualified-artifact-tuple.json`](polyglot/qualified-artifact-tuple.json)
4444
pins every published artifact the journeys use: native Durable Workflow Server
45-
3.0.4 (by image digest), Workflow 2.20.1, Waterline 2.13.2, PHP SDK 3.8.1,
45+
3.0.4 (by image digest), Workflow 2.20.1, Waterline 2.13.2, PHP SDK 3.9.1,
4646
Python SDK 2.13.0, Rust SDK 4.10.3 and CLI 2.3.3. `artifacts.workflow` selects
4747
the embedded application's Composer package; the Composer lock must match it.
4848
Native Server is one process that creates its schema and `default` namespace
@@ -51,6 +51,35 @@ bootstrap job, queue worker, scheduler or Server Redis. Override the Server with
5151
`SAMPLE_APP_SERVER_VERSION` or `SAMPLE_APP_SERVER_IMAGE`; only native 3.x images
5252
are accepted.
5353

54+
### Conversation on a PHP service-mode worker
55+
56+
`App\Workflows\ServiceMode\Conversation\ConversationWorkflow` is the
57+
[embedded conversation](#example-index) written for the PHP SDK's Laravel
58+
bridge. It has the same turns, approval rule and continue-as-new, and the same
59+
durations, written as strings:
60+
61+
- the conversation ends after `'1 hour'` without a message;
62+
- a proposal nobody decides within `'5 minutes'` changes nothing;
63+
- each activity has a `'30 seconds'` start-to-close timeout.
64+
65+
The SDK records each string as whole seconds, so `'5 minutes'` and `300`
66+
produce the same history. Point the application at a Server, then run a worker
67+
and the scripted conversation in two terminals:
68+
69+
```bash
70+
export DURABLE_WORKFLOW_RUNTIME_URL=http://localhost:8080
71+
72+
DURABLE_WORKFLOW_PROCESS_ROLE=worker DURABLE_WORKFLOW_PROCESS_TOKEN='<worker credential>' \
73+
php artisan durable-workflow:worker
74+
75+
DURABLE_WORKFLOW_PROCESS_ROLE=client DURABLE_WORKFLOW_PROCESS_TOKEN='<client credential>' \
76+
php artisan app:service-mode-conversation --turns-per-run=2 \
77+
--message='Hello there' --message='Please refund my order' --message='Thanks'
78+
```
79+
80+
Both processes use the application database for reply records, so run
81+
`php artisan migrate` first. Without `--message` the command is interactive.
82+
5483
### Published child cancellation experiment
5584

5685
The full child experiment runs PHP, Python and Rust workers and cancellation
@@ -209,6 +238,7 @@ tests under [tests/Feature/Workflows](tests/Feature/Workflows).
209238
| `SandboxAgentWorkflow` | Durable sandbox provisioning, snapshots, recovery, and cleanup |
210239
| `PolyglotWorkflow` | PHP workflow with Python and Rust activities |
211240
| `ConversationWorkflow` (`php artisan app:conversation`) | Long-lived conversation: ordered signal input, fake model activity, human approval, status query, continue-as-new, and recovery after a lost worker |
241+
| Service-mode `ConversationWorkflow` (`php artisan app:service-mode-conversation`) | The same conversation on a PHP SDK worker, with `'1 hour'` and `'5 minutes'` written as friendly durations |
212242

213243
## Repository map
214244

‎app/Console/Commands/Conversation.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public function handle(): int
2424
{
2525
$turnsPerRun = max(1, (int) $this->option('turns-per-run'));
2626
$workflow = WorkflowStub::make(ConversationWorkflow::class);
27-
$workflow->start([], $turnsPerRun, 3600);
27+
$workflow->start([], $turnsPerRun);
2828

2929
$this->info(sprintf('Conversation %s started. Run a queue worker to process it.', $workflow->id()));
3030

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands;
6+
7+
use App\Models\ConversationRecord;
8+
use App\Workflows\Conversation\ConversationStore;
9+
use App\Workflows\ServiceMode\Conversation\ConversationWorkflow;
10+
use DurableWorkflow\Bridge\Laravel\LaravelWorkflowClientInterface;
11+
use DurableWorkflow\WorkflowHandleInterface;
12+
use Illuminate\Console\Command;
13+
use Illuminate\Support\Str;
14+
15+
final class ServiceModeConversation extends Command
16+
{
17+
protected $signature = 'app:service-mode-conversation
18+
{--message=* : Scripted user message; repeat for several turns. Without it the command is interactive}
19+
{--decision=approve : Scripted answer to an approval request (approve or decline)}
20+
{--turns-per-run=3 : Turns before the run continues as new}
21+
{--workflow-id= : Use a specific workflow ID}';
22+
23+
protected $description = 'Run the conversation reference on a PHP service-mode worker';
24+
25+
private const WAIT_SECONDS = 60;
26+
27+
public function __construct(private readonly LaravelWorkflowClientInterface $workflows)
28+
{
29+
parent::__construct();
30+
}
31+
32+
public function handle(): int
33+
{
34+
$workflowId = $this->option('workflow-id') ?: 'service-conversation-'.Str::lower((string) Str::ulid());
35+
$workflow = $this->workflows->start(
36+
ConversationWorkflow::class,
37+
[[], max(1, (int) $this->option('turns-per-run'))],
38+
$workflowId,
39+
);
40+
41+
$this->info("Conversation {$workflowId} started. A service-mode worker processes it.");
42+
43+
$scripted = $this->option('message');
44+
$messages = $scripted !== [] ? $scripted : null;
45+
$turn = 0;
46+
47+
while (true) {
48+
$text = $messages !== null ? array_shift($messages) : $this->ask('You ('.ConversationWorkflow::END_COMMAND.' to finish)');
49+
50+
if ($text === null || trim($text) === '' || trim($text) === ConversationWorkflow::END_COMMAND) {
51+
break;
52+
}
53+
54+
if ($messages !== null) {
55+
$this->line("<comment>You:</comment> {$text}");
56+
}
57+
58+
$turn++;
59+
60+
if (! $this->takeTurn($workflow, $workflowId, $turn, $text, $messages !== null)) {
61+
return self::FAILURE;
62+
}
63+
}
64+
65+
$workflow->signal('message', [ConversationWorkflow::END_COMMAND]);
66+
$this->line(json_encode($workflow->result(self::WAIT_SECONDS, 0.25), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
67+
68+
return self::SUCCESS;
69+
}
70+
71+
/**
72+
* Sends one message and answers any approval request until the turn's
73+
* reply is stored.
74+
*
75+
* The `status` query describes the current run, and a turn can end with
76+
* continue-as-new. The reply record is keyed by workflow ID and turn, so
77+
* it is the reliable sign that the turn finished.
78+
*/
79+
private function takeTurn(WorkflowHandleInterface $workflow, string $workflowId, int $turn, string $text, bool $scripted): bool
80+
{
81+
$reference = ConversationStore::replyReference($workflowId, $turn);
82+
$workflow->signal('message', [$text]);
83+
$answered = null;
84+
85+
while (true) {
86+
$reply = null;
87+
$proposal = null;
88+
$ready = $this->waitUntil(function () use ($workflow, $reference, $answered, &$reply, &$proposal): bool {
89+
$reply = ConversationRecord::query()->find($reference);
90+
$proposal = $reply === null ? $this->status($workflow)['pending_proposal'] : null;
91+
92+
return $reply !== null || ($proposal !== null && $proposal['id'] !== $answered);
93+
});
94+
95+
if (! $ready) {
96+
return false;
97+
}
98+
99+
if ($reply !== null) {
100+
break;
101+
}
102+
103+
$approved = $scripted
104+
? $this->option('decision') === 'approve'
105+
: $this->confirm(sprintf('Approve "%s"?', $proposal['description']));
106+
107+
$this->line(sprintf('<comment>Reviewer:</comment> %s', $approved ? 'approve' : 'decline'));
108+
$workflow->signal('decision', [$proposal['id'], $approved, 'artisan']);
109+
$answered = $proposal['id'];
110+
}
111+
112+
$this->line(sprintf('<info>Assistant:</info> %s', $reply->content));
113+
114+
return true;
115+
}
116+
117+
/**
118+
* @return array<string, mixed>
119+
*/
120+
private function status(WorkflowHandleInterface $workflow): array
121+
{
122+
return $workflow->query('status');
123+
}
124+
125+
private function waitUntil(callable $condition): bool
126+
{
127+
$deadline = time() + self::WAIT_SECONDS;
128+
129+
while (time() < $deadline) {
130+
if ($condition()) {
131+
return true;
132+
}
133+
134+
usleep(250_000);
135+
}
136+
137+
$this->error('Timed out waiting for the conversation. Is `php artisan durable-workflow:worker` running?');
138+
139+
return false;
140+
}
141+
}

‎app/Workflows/Conversation/ApplyApprovedActionActivity.php‎

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,11 @@
44

55
namespace App\Workflows\Conversation;
66

7-
use App\Models\ConversationRecord;
87
use Workflow\V2\Activity;
98

109
/**
11-
* Performs the side effect a reviewer approved.
12-
*
13-
* The receipt row stands in for a call to an external system. Pass the same
14-
* reference to that system as its idempotency key: the activity can run more
15-
* than once for one approval, and the engine does not make the external
16-
* effect exactly-once.
10+
* Performs the side effect a reviewer approved, keyed by an idempotency
11+
* reference so a repeated execution does not apply it twice.
1712
*/
1813
class ApplyApprovedActionActivity extends Activity
1914
{
@@ -24,23 +19,6 @@ class ApplyApprovedActionActivity extends Activity
2419
*/
2520
public function handle(array $proposal, string $reviewer): string
2621
{
27-
$reference = ConversationWorkflow::actionReference($this->workflowId(), $proposal['turn']);
28-
29-
ConversationRecord::query()->firstOrCreate(
30-
['reference' => $reference],
31-
[
32-
'workflow_id' => $this->workflowId(),
33-
'run_id' => $this->runId(),
34-
'kind' => 'action',
35-
'turn' => $proposal['turn'],
36-
'content' => json_encode([
37-
'action' => $proposal['action'],
38-
'description' => $proposal['description'],
39-
'approved_by' => $reviewer,
40-
], JSON_THROW_ON_ERROR),
41-
],
42-
);
43-
44-
return $reference;
22+
return ConversationStore::recordApprovedAction($this->workflowId(), $this->runId(), $proposal, $reviewer);
4523
}
4624
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Workflows\Conversation;
6+
7+
use App\Models\ConversationRecord;
8+
9+
/**
10+
* External payload storage shared by the embedded and service-mode
11+
* conversations.
12+
*
13+
* Activities are at-least-once: a worker can write a row and then lose its
14+
* lease before reporting completion, and the retry runs this code again. Each
15+
* reference is derived from the workflow ID and the conversation-wide turn
16+
* number, so a repeated execution reuses the same row.
17+
*/
18+
final class ConversationStore
19+
{
20+
public static function replyReference(string $workflowId, int $turn): string
21+
{
22+
return sprintf('conversation:%s:%d:reply', $workflowId, $turn);
23+
}
24+
25+
public static function actionReference(string $workflowId, int $turn): string
26+
{
27+
return sprintf('conversation:%s:%d:action', $workflowId, $turn);
28+
}
29+
30+
public static function recordReply(string $workflowId, string $runId, int $turn, string $content): string
31+
{
32+
$reference = self::replyReference($workflowId, $turn);
33+
34+
ConversationRecord::query()->updateOrCreate(
35+
['reference' => $reference],
36+
[
37+
'workflow_id' => $workflowId,
38+
'run_id' => $runId,
39+
'kind' => 'reply',
40+
'turn' => $turn,
41+
'content' => $content,
42+
],
43+
);
44+
45+
return $reference;
46+
}
47+
48+
/**
49+
* The receipt row stands in for a call to an external system. Pass the
50+
* same reference to that system as its idempotency key: the activity can
51+
* run more than once for one approval, and the engine does not make the
52+
* external effect exactly-once.
53+
*
54+
* @param array{id: string, action: string, description: string, turn: int} $proposal
55+
*/
56+
public static function recordApprovedAction(string $workflowId, string $runId, array $proposal, string $reviewer): string
57+
{
58+
$reference = self::actionReference($workflowId, $proposal['turn']);
59+
60+
ConversationRecord::query()->firstOrCreate(
61+
['reference' => $reference],
62+
[
63+
'workflow_id' => $workflowId,
64+
'run_id' => $runId,
65+
'kind' => 'action',
66+
'turn' => $proposal['turn'],
67+
'content' => json_encode([
68+
'action' => $proposal['action'],
69+
'description' => $proposal['description'],
70+
'approved_by' => $reviewer,
71+
], JSON_THROW_ON_ERROR),
72+
],
73+
);
74+
75+
return $reference;
76+
}
77+
}

0 commit comments

Comments
 (0)