Skip to content

Commit 00a353a

Browse files
committed
[ADD] Local sTask lifecycle proof (queued -> completed) in demo runtime verification logs and tests
1 parent 8e5da35 commit 00a353a

7 files changed

Lines changed: 223 additions & 0 deletions

File tree

PRD.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- Runtime HTTP integration (`tests/Integration/RuntimeIntegrationHttpTest.php`) -> PASS.
1919
- Підтверджено API шлях читання даних з БД через MCP (`evo.content.search`, `evo.content.root_tree`, `evo.content.get`).
2020
- Автоматично генерується `demo/logs.md` з деталями токена, MCP запитів/відповідей, manual-check командами і негативними probe-кейсами (`401/403/413/415/409/429`, `evo.model.get(User)` sanity).
21+
- `demo/logs.md` додатково включає локальний `sTask` lifecycle proof (`queued -> completed`) через `php artisan stask:worker`.
2122

2223
Залишок до RC-1 (core platform hardening):
2324
- branch protection required-check enforcement для CI runtime jobs (`demo-runtime-proof`, `runtime-integration`) на `release/*`;

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ make demo-all
239239
This target installs demo Evo, starts `php -S`, issues sApi JWT, runs `php artisan emcp:test`, then runs `composer run test` with HTTP runtime integration enabled.
240240
After run, detailed evidence is written to:
241241
- `demo/logs.md` (token/masked auth info, MCP request payloads, HTTP statuses, responses, manual verification commands, plus negative probes: 401/403/413/415/409/429 and `evo.model.get(User)` sanity)
242+
- `demo/logs.md` also includes local `sTask` lifecycle proof (`queued -> completed`) via `php artisan stask:worker` in demo runtime.
242243
- `/tmp/emcp-demo-php-server.log` (php built-in server log)
243244

244245
If GitHub API auth is needed during install, pass token via ENV (same pattern as `evolution`):

README.uk.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ make demo-all
238238
Ця ціль встановлює demo Evo, запускає `php -S`, видає sApi JWT, виконує `php artisan emcp:test`, а потім `composer run test` з увімкненою HTTP runtime integration перевіркою.
239239
Після виконання детальні докази записуються у:
240240
- `demo/logs.md` (masked token, MCP payload-и, HTTP статуси, відповіді, manual verification команди, а також negative probes: 401/403/413/415/409/429 і sanity check для `evo.model.get(User)`)
241+
- `demo/logs.md` також містить локальний proof для lifecycle `sTask` (`queued -> completed`) через `php artisan stask:worker` у demo runtime.
241242
- `/tmp/emcp-demo-php-server.log` (лог PHP built-in server)
242243

243244
Якщо під час інсталяції потрібна GitHub авторизація API, передай токен через ENV (як у `evolution`):

SPEC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Current validation snapshot (2026-03-03):
1818
- Runtime HTTP integration PASS for `/api/v1/mcp/{server}`.
1919
- Verified content-read tool flow in runtime (`evo.content.search`, `evo.content.root_tree`, `evo.content.get`).
2020
- One-click verification writes `demo/logs.md` with request/response evidence, negative transport/security probes (`401/403/413/415/409/429`) and model-safety sanity (`evo.model.get(User)` without sensitive fields).
21+
- `demo/logs.md` also captures local `sTask` lifecycle proof (`queued -> completed`) in demo runtime.
2122

2223
Open RC-1 validation scope:
2324
- live CI runtime integration jobs are wired for `release/*` pushes (`demo-runtime-proof`, `runtime-integration`), but branch-protection required-check enforcement must be configured in repository settings;

TASKS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ DoD:
237237
- [ ] Configure repository branch protection to require `demo-runtime-proof` and `runtime-integration` on `release/*`.
238238
- [ ] Streaming tests under typical PHP-FPM constraints.
239239
- [ ] Async tests for `sTask` path and failover.
240+
- [x] Add local demo `sTask` lifecycle proof in `demo/logs.md` (`queued -> completed`) using `php artisan stask:worker`.
240241
- [x] Baseline feature-behavior test for dispatch idempotency semantics (`reuse` and `409 conflict`) with policy deny path.
241242
- [ ] Functional tests for `SiteContent` tree/TV tool contracts.
242243
- [ ] Security tests for forbidden fields and invalid TV operators/casts.

scripts/demo_verify.sh

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@ EMCP_RUNTIME_NEGATIVE=1 \
325325
EMCP_RUNTIME_MODEL_SANITY=1 \
326326
EMCP_RUNTIME_NEGATIVE_REQUIRE_RATE_LIMIT=1 \
327327
EMCP_TEST_JWT_SECRET="${SAPI_JWT_SECRET}" \
328+
EMCP_STASK_LIFECYCLE_CHECK=1 \
329+
EMCP_STASK_WORKER_CMD="php artisan stask:worker" \
330+
EMCP_STASK_WORKER_CWD="${DEMO_CORE_DIR_PATH}" \
331+
EMCP_STASK_POLL_ATTEMPTS=20 \
328332
composer run test
329333
test_exit=$?
330334
set -e
@@ -466,6 +470,69 @@ dispatch_conflict_raw=$(mcp_post_with_token_optional_session "${probe_token}" "$
466470
dispatch_conflict_http_code=$(printf '%s' "${dispatch_conflict_raw}" | sed -n 's/^__HTTP_CODE__://p' | tail -n 1)
467471
dispatch_conflict_response=$(printf '%s' "${dispatch_conflict_raw}" | sed '/^__HTTP_CODE__:/d')
468472

473+
dispatch_lifecycle_key='demo-verify-k2'
474+
dispatch_lifecycle_start_headers="${TMP_DIR}/dispatch-lifecycle-start.headers"
475+
dispatch_lifecycle_start_raw=$(mcp_post_with_token_optional_session "${probe_token}" "${dispatch_payload_a}" "${dispatch_lifecycle_start_headers}" "Idempotency-Key: ${dispatch_lifecycle_key}" "${dispatch_url}")
476+
dispatch_lifecycle_start_http_code=$(printf '%s' "${dispatch_lifecycle_start_raw}" | sed -n 's/^__HTTP_CODE__://p' | tail -n 1)
477+
dispatch_lifecycle_start_response=$(printf '%s' "${dispatch_lifecycle_start_raw}" | sed '/^__HTTP_CODE__:/d')
478+
dispatch_lifecycle_task_id=$(printf '%s' "${dispatch_lifecycle_start_response}" | php -r '
479+
$raw = stream_get_contents(STDIN);
480+
$json = json_decode($raw, true);
481+
$taskId = $json["task_id"] ?? null;
482+
if (is_numeric($taskId) && (int)$taskId > 0) {
483+
echo (string)(int)$taskId;
484+
}
485+
')
486+
487+
stask_worker_exit=0
488+
stask_worker_output=""
489+
if [ -n "${dispatch_lifecycle_task_id}" ]; then
490+
set +e
491+
stask_worker_output=$(cd "${DEMO_CORE_DIR_PATH}" && php artisan stask:worker 2>&1)
492+
stask_worker_exit=$?
493+
set -e
494+
else
495+
stask_worker_exit=1
496+
stask_worker_output="No task_id returned from lifecycle dispatch start; async path unavailable."
497+
fi
498+
499+
dispatch_lifecycle_after_headers="${TMP_DIR}/dispatch-lifecycle-after.headers"
500+
dispatch_lifecycle_after_raw=$(mcp_post_with_token_optional_session "${probe_token}" "${dispatch_payload_a}" "${dispatch_lifecycle_after_headers}" "Idempotency-Key: ${dispatch_lifecycle_key}" "${dispatch_url}")
501+
dispatch_lifecycle_after_http_code=$(printf '%s' "${dispatch_lifecycle_after_raw}" | sed -n 's/^__HTTP_CODE__://p' | tail -n 1)
502+
dispatch_lifecycle_after_response=$(printf '%s' "${dispatch_lifecycle_after_raw}" | sed '/^__HTTP_CODE__:/d')
503+
504+
stask_lifecycle_result=$(DISPATCH_START="${dispatch_lifecycle_start_response}" DISPATCH_AFTER="${dispatch_lifecycle_after_response}" WORKER_EXIT="${stask_worker_exit}" php -r '
505+
$start = json_decode((string)getenv("DISPATCH_START"), true);
506+
$after = json_decode((string)getenv("DISPATCH_AFTER"), true);
507+
$workerExit = (int)getenv("WORKER_EXIT");
508+
if (!is_array($start)) {
509+
echo "FAILED: lifecycle start response is not JSON";
510+
exit(0);
511+
}
512+
if (!is_array($after)) {
513+
echo "FAILED: lifecycle after-worker response is not JSON";
514+
exit(0);
515+
}
516+
if ($workerExit !== 0) {
517+
echo "FAILED: stask worker exited with code " . $workerExit;
518+
exit(0);
519+
}
520+
if (($after["reused"] ?? null) !== true) {
521+
echo "FAILED: lifecycle reuse response has reused!=true";
522+
exit(0);
523+
}
524+
if (($after["status"] ?? null) !== "completed") {
525+
echo "FAILED: lifecycle status is not completed";
526+
exit(0);
527+
}
528+
$result = $after["result"] ?? null;
529+
if (!is_array($result)) {
530+
echo "FAILED: lifecycle completed response has no result payload";
531+
exit(0);
532+
}
533+
echo "PASS: queued -> completed with persisted result";
534+
')
535+
469536
model_headers_file="${TMP_DIR}/model.headers"
470537
model_raw=$(mcp_post_with_token_optional_session "${probe_token}" "${model_get_user_payload}" "${model_headers_file}")
471538
model_http_code=$(printf '%s' "${model_raw}" | sed -n 's/^__HTTP_CODE__://p' | tail -n 1)
@@ -705,6 +772,33 @@ Response:
705772
${model_response}
706773
\`\`\`
707774
775+
### 13) local sTask lifecycle probe (queued -> completed)
776+
777+
Dispatch start HTTP: \`${dispatch_lifecycle_start_http_code}\`
778+
Task ID: \`${dispatch_lifecycle_task_id:-n/a}\`
779+
780+
Dispatch start response:
781+
\`\`\`json
782+
${dispatch_lifecycle_start_response}
783+
\`\`\`
784+
785+
Worker command: \`php artisan stask:worker\`
786+
Worker exit code: \`${stask_worker_exit}\`
787+
788+
Worker output:
789+
\`\`\`
790+
${stask_worker_output}
791+
\`\`\`
792+
793+
Dispatch after worker HTTP: \`${dispatch_lifecycle_after_http_code}\`
794+
795+
Dispatch after worker response:
796+
\`\`\`json
797+
${dispatch_lifecycle_after_response}
798+
\`\`\`
799+
800+
Lifecycle result: \`${stask_lifecycle_result}\`
801+
708802
## How To Verify Manually
709803
710804
1. Get token:
@@ -742,6 +836,12 @@ curl -sS -H 'Content-Type: application/json' -H 'Authorization: Bearer <TOKEN>'
742836
-d '${model_get_user_payload}' \\
743837
'${mcp_url}'
744838
\`\`\`
839+
840+
6. Local sTask worker run (optional lifecycle proof in demo):
841+
\`\`\`bash
842+
cd '${DEMO_CORE_DIR_PATH}'
843+
php artisan stask:worker
844+
\`\`\`
745845
EOF
746846

747847
echo "[demo-verify] Wrote detailed run log: ${LOGS_MD}"

tests/Integration/RuntimeIntegrationHttpTest.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,44 @@ function issueHs256Jwt(array $payload, string $secret): string
282282
return $signingInput . '.' . $encode($signature);
283283
}
284284

285+
/**
286+
* @return array{exit:int, output:string}
287+
*/
288+
function runShellCommand(string $command, string $cwd = ''): array
289+
{
290+
if (function_exists('proc_open')) {
291+
$descriptors = [
292+
0 => ['pipe', 'r'],
293+
1 => ['pipe', 'w'],
294+
2 => ['pipe', 'w'],
295+
];
296+
$proc = proc_open($command, $descriptors, $pipes, $cwd !== '' ? $cwd : null);
297+
if (is_resource($proc)) {
298+
fclose($pipes[0]);
299+
$stdout = stream_get_contents($pipes[1]);
300+
fclose($pipes[1]);
301+
$stderr = stream_get_contents($pipes[2]);
302+
fclose($pipes[2]);
303+
$exit = proc_close($proc);
304+
305+
return [
306+
'exit' => is_int($exit) ? $exit : 1,
307+
'output' => trim((string)$stdout . (string)$stderr),
308+
];
309+
}
310+
}
311+
312+
$prefix = $cwd !== '' ? 'cd ' . escapeshellarg($cwd) . ' && ' : '';
313+
$outputLines = [];
314+
$exit = 1;
315+
@exec($prefix . $command . ' 2>&1', $outputLines, $exit);
316+
317+
return [
318+
'exit' => $exit,
319+
'output' => trim(implode("\n", $outputLines)),
320+
];
321+
}
322+
285323
$enabled = getenv('EMCP_INTEGRATION_ENABLED');
286324
if ($enabled !== '1') {
287325
info('Skipped (set EMCP_INTEGRATION_ENABLED=1 to run runtime integration checks).');
@@ -340,6 +378,13 @@ function issueHs256Jwt(array $payload, string $secret): string
340378
if ($rateProbeMaxAttempts < 1) {
341379
$rateProbeMaxAttempts = 90;
342380
}
381+
$runStaskLifecycleCheck = getenv('EMCP_STASK_LIFECYCLE_CHECK') === '1';
382+
$staskWorkerCommand = trim((string)getenv('EMCP_STASK_WORKER_CMD'));
383+
$staskWorkerCwd = trim((string)getenv('EMCP_STASK_WORKER_CWD'));
384+
$staskPollAttempts = (int)getenv('EMCP_STASK_POLL_ATTEMPTS');
385+
if ($staskPollAttempts < 1) {
386+
$staskPollAttempts = 20;
387+
}
343388

344389
$readOnlyToken = trim((string)getenv('EMCP_TEST_JWT_READ_TOKEN'));
345390
$jwtSecret = trim((string)getenv('EMCP_TEST_JWT_SECRET'));
@@ -683,6 +728,79 @@ function issueHs256Jwt(array $payload, string $secret): string
683728
if ($traceId === '') {
684729
fail("{$label} dispatch conflict missing error.trace_id.");
685730
}
731+
732+
if ($runStaskLifecycleCheck && $label === 'api') {
733+
if ($staskWorkerCommand === '' || $staskWorkerCwd === '') {
734+
fail("{$label} sTask lifecycle check requires EMCP_STASK_WORKER_CMD and EMCP_STASK_WORKER_CWD.");
735+
}
736+
737+
$lifecycleKey = 'runtime-k-lifecycle';
738+
$dispatchLifecycleStart = httpPostJson($dispatchUrl, [
739+
'jsonrpc' => '2.0',
740+
'id' => 'dl1',
741+
'method' => 'tools/call',
742+
'params' => [
743+
'name' => 'evo.content.search',
744+
'arguments' => ['limit' => 1, 'offset' => 0],
745+
],
746+
], array_merge($callHeaders, ['Idempotency-Key: ' . $lifecycleKey]));
747+
748+
if (!in_array($dispatchLifecycleStart['status'], [200, 202], true)) {
749+
fail("{$label} sTask lifecycle start expected 200/202, got {$dispatchLifecycleStart['status']}.");
750+
}
751+
752+
$dispatchLifecycleStartJson = decodeJson($dispatchLifecycleStart['body']);
753+
$dispatchLifecycleTaskId = is_numeric($dispatchLifecycleStartJson['task_id'] ?? null)
754+
? (int)$dispatchLifecycleStartJson['task_id']
755+
: 0;
756+
757+
if ($dispatchLifecycleTaskId < 1) {
758+
fail("{$label} sTask lifecycle expected async task_id > 0, got " . (string)($dispatchLifecycleStartJson['task_id'] ?? 'null') . '.');
759+
}
760+
761+
$workerRun = runShellCommand($staskWorkerCommand, $staskWorkerCwd);
762+
if ($workerRun['exit'] !== 0) {
763+
fail("{$label} sTask worker command failed (exit {$workerRun['exit']}): " . $workerRun['output']);
764+
}
765+
766+
$completed = false;
767+
for ($i = 1; $i <= $staskPollAttempts; $i++) {
768+
$dispatchLifecycleAfter = httpPostJson($dispatchUrl, [
769+
'jsonrpc' => '2.0',
770+
'id' => 'dl2-' . $i,
771+
'method' => 'tools/call',
772+
'params' => [
773+
'name' => 'evo.content.search',
774+
'arguments' => ['limit' => 1, 'offset' => 0],
775+
],
776+
], array_merge($callHeaders, ['Idempotency-Key: ' . $lifecycleKey]));
777+
778+
if (!in_array($dispatchLifecycleAfter['status'], [200, 202], true)) {
779+
fail("{$label} sTask lifecycle reuse expected 200/202, got {$dispatchLifecycleAfter['status']}.");
780+
}
781+
782+
$dispatchLifecycleAfterJson = decodeJson($dispatchLifecycleAfter['body']);
783+
if (($dispatchLifecycleAfterJson['reused'] ?? null) !== true) {
784+
fail("{$label} sTask lifecycle reuse must return reused=true.");
785+
}
786+
787+
$lifecycleStatus = trim((string)($dispatchLifecycleAfterJson['status'] ?? ''));
788+
if ($lifecycleStatus === 'completed') {
789+
$result = $dispatchLifecycleAfterJson['result'] ?? null;
790+
if (!is_array($result)) {
791+
fail("{$label} sTask lifecycle completed response missing result payload.");
792+
}
793+
$completed = true;
794+
break;
795+
}
796+
797+
usleep(300000);
798+
}
799+
800+
if (!$completed) {
801+
fail("{$label} sTask lifecycle did not reach completed status after {$staskPollAttempts} polls.");
802+
}
803+
}
686804
}
687805

688806
info("{$label} checks passed.");

0 commit comments

Comments
 (0)