diff --git a/Cargo.lock b/Cargo.lock index ea77ea7fc..12a5d7800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5324,7 +5324,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petri-attractor-steps" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "globset", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "petri-driver" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "getrandom 0.3.4", @@ -5375,7 +5375,7 @@ dependencies = [ [[package]] name = "petri-engine" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "petri-ir", "serde", @@ -5387,7 +5387,7 @@ dependencies = [ [[package]] name = "petri-execution" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "petri-driver", @@ -5411,7 +5411,7 @@ dependencies = [ [[package]] name = "petri-executor" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "libc", @@ -5426,7 +5426,7 @@ dependencies = [ [[package]] name = "petri-executor-sandbox" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "petri-executor", @@ -5448,7 +5448,7 @@ dependencies = [ [[package]] name = "petri-frontend" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "marked-yaml", "petri-ir", @@ -5462,7 +5462,7 @@ dependencies = [ [[package]] name = "petri-frontend-attractor" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "minijinja", "petri-frontend", @@ -5479,7 +5479,7 @@ dependencies = [ [[package]] name = "petri-frontend-fabro" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "petri-frontend", "petri-frontend-attractor", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "petri-frontend-native" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "petri-frontend", "petri-ir", @@ -5506,7 +5506,7 @@ dependencies = [ [[package]] name = "petri-ir" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "regex", "serde", @@ -5519,7 +5519,7 @@ dependencies = [ [[package]] name = "petri-runtime" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "petri-driver", @@ -5540,7 +5540,7 @@ dependencies = [ [[package]] name = "petri-steps" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "petri-executor", @@ -5556,7 +5556,7 @@ dependencies = [ [[package]] name = "petri-store" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "getrandom 0.3.4", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "petri-testkit" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?rev=4d4bdd694aa573514968f8e8321123146d2d2458#4d4bdd694aa573514968f8e8321123146d2d2458" +source = "git+https://github.com/lithoscomputer/petri.git?rev=a5906f6554b94f608ede3902daab106d5f8062c3#a5906f6554b94f608ede3902daab106d5f8062c3" dependencies = [ "async-trait", "petri-driver", diff --git a/Cargo.toml b/Cargo.toml index 1f785630d..fe886bdf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,13 +132,13 @@ pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "a39 # lithos-llm and sandbox-driver revisions as this file, so the workspace links # one copy of each. Only `fabro-petri` may depend on these packages; the keys # carry the `petri_` prefix so the crate names say where they come from. -petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "4d4bdd694aa573514968f8e8321123146d2d2458", package = "petri-runtime" } -petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "4d4bdd694aa573514968f8e8321123146d2d2458", package = "petri-execution" } -petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "4d4bdd694aa573514968f8e8321123146d2d2458", package = "petri-store" } -petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "4d4bdd694aa573514968f8e8321123146d2d2458", package = "petri-attractor-steps" } -petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "4d4bdd694aa573514968f8e8321123146d2d2458", package = "petri-frontend-attractor" } -petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "4d4bdd694aa573514968f8e8321123146d2d2458", package = "petri-frontend-fabro" } -petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "4d4bdd694aa573514968f8e8321123146d2d2458", package = "petri-testkit" } +petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "a5906f6554b94f608ede3902daab106d5f8062c3", package = "petri-runtime" } +petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "a5906f6554b94f608ede3902daab106d5f8062c3", package = "petri-execution" } +petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "a5906f6554b94f608ede3902daab106d5f8062c3", package = "petri-store" } +petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "a5906f6554b94f608ede3902daab106d5f8062c3", package = "petri-attractor-steps" } +petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "a5906f6554b94f608ede3902daab106d5f8062c3", package = "petri-frontend-attractor" } +petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "a5906f6554b94f608ede3902daab106d5f8062c3", package = "petri-frontend-fabro" } +petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "a5906f6554b94f608ede3902daab106d5f8062c3", package = "petri-testkit" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/lib/apps/fabro-cli/tests/it/cmd/attach.rs b/lib/apps/fabro-cli/tests/it/cmd/attach.rs index 81df5cc9a..94eb5d15e 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/attach.rs @@ -902,7 +902,7 @@ fn attach_json_errors_without_prompting_for_human_input() { "recorded_at": "[EPOCH_MS]", "body": { "event": "run.started", - "format_version": 5, + "format_version": 6, "key": "[ULID]", "root": 0, "middleware_chain": [ @@ -1323,46 +1323,20 @@ fn attach_json_errors_without_prompting_for_human_input() { "invocation": 0, "execution": 0 }, - "subject": { - "node": { - "id": 0, - "name": "start", - "kind": "attractor/stage", - "meta": { - "label": "Start", - "shape": "Mdiamond", - "kind": "start", - "classes": [], - "span": { - "line": 3, - "column": 3 - }, - "admission_hooks": "step", - "edges": { - "0": { - "to": "approve", - "label": null - } - } - } - }, - "firing": 1, - "visit": 1, - "attempt": 1, - "generation": 0, - "branch": { - "role": "none" - } - }, "recorded_at": "[EPOCH_MS]", "record": { "seq": 4, "origin": "external", "recorded_at": "[EPOCH_MS]", "body": { - "event": "step.started", - "firing": 1, - "attempt": 1 + "event": "scope.acquired", + "scope": 0, + "lease": 0, + "workspace": "invocation-0-scope-0", + "provider": "host", + "instance": "host-g[ID]", + "working_directory": "[RUN_DIR]/petri/scopes/invocation-0-scope-0/work", + "duration_ms": "[DURATION_MS]" } } } @@ -1371,62 +1345,6 @@ fn attach_json_errors_without_prompting_for_human_input() { "run_id": "[ULID]", "stream_seq": 18, "kind": "petri", - "id": "execution 0/4/1", - "recorded_at": "[EPOCH_MS]", - "item": { - "id": { - "log": "execution", - "execution": 0, - "seq": 4, - "index": 1 - }, - "origin": "derived", - "context": { - "invocation": 0, - "execution": 0 - }, - "subject": { - "node": { - "id": 0, - "name": "start", - "kind": "attractor/stage", - "meta": { - "label": "Start", - "shape": "Mdiamond", - "kind": "start", - "classes": [], - "span": { - "line": 3, - "column": 3 - }, - "admission_hooks": "step", - "edges": { - "0": { - "to": "approve", - "label": null - } - } - } - }, - "firing": 1, - "visit": 1, - "attempt": 1, - "generation": 0, - "branch": { - "role": "none" - } - }, - "recorded_at": "[EPOCH_MS]", - "derived": { - "event": "wait.state.changed", - "state": "running" - } - } - }, - { - "run_id": "[ULID]", - "stream_seq": 19, - "kind": "petri", "id": "execution 0/5/0", "recorded_at": "[EPOCH_MS]", "item": { @@ -1478,15 +1396,66 @@ fn attach_json_errors_without_prompting_for_human_input() { "origin": "external", "recorded_at": "[EPOCH_MS]", "body": { - "event": "step.progress.recorded", + "event": "step.started", "firing": 1, - "ev": { - "log": { - "stream": "stderr", - "line": "checkout: [TEMP_DIR] is not a Git repository; the workspace starts empty" + "attempt": 1 + } + } + } + }, + { + "run_id": "[ULID]", + "stream_seq": 19, + "kind": "petri", + "id": "execution 0/5/1", + "recorded_at": "[EPOCH_MS]", + "item": { + "id": { + "log": "execution", + "execution": 0, + "seq": 5, + "index": 1 + }, + "origin": "derived", + "context": { + "invocation": 0, + "execution": 0 + }, + "subject": { + "node": { + "id": 0, + "name": "start", + "kind": "attractor/stage", + "meta": { + "label": "Start", + "shape": "Mdiamond", + "kind": "start", + "classes": [], + "span": { + "line": 3, + "column": 3 + }, + "admission_hooks": "step", + "edges": { + "0": { + "to": "approve", + "label": null + } } } + }, + "firing": 1, + "visit": 1, + "attempt": 1, + "generation": 0, + "branch": { + "role": "none" } + }, + "recorded_at": "[EPOCH_MS]", + "derived": { + "event": "wait.state.changed", + "state": "running" } } }, @@ -1548,34 +1517,9 @@ fn attach_json_errors_without_prompting_for_human_input() { "event": "step.progress.recorded", "firing": 1, "ev": { - "custom": { - "$note": { - "kind": "fabro.checkpoint", - "payload": { - "execution": 0, - "firing": 1, - "attempt": 1, - "workspace": "invocation-0-scope-0", - "git_commit_sha": "[DIGEST]", - "reused": false - } - } - } - } - } - }, - "derived": { - "parsed": { - "kind": "note", - "note": { - "kind": "fabro.checkpoint", - "payload": { - "execution": 0, - "firing": 1, - "attempt": 1, - "workspace": "invocation-0-scope-0", - "git_commit_sha": "[DIGEST]", - "reused": false + "log": { + "stream": "stderr", + "line": "checkout: [TEMP_DIR] is not a Git repository; the workspace starts empty" } } } @@ -1636,6 +1580,98 @@ fn attach_json_errors_without_prompting_for_human_input() { "seq": 7, "origin": "external", "recorded_at": "[EPOCH_MS]", + "body": { + "event": "step.progress.recorded", + "firing": 1, + "ev": { + "custom": { + "$note": { + "kind": "fabro.checkpoint", + "payload": { + "execution": 0, + "firing": 1, + "attempt": 1, + "workspace": "invocation-0-scope-0", + "git_commit_sha": "[DIGEST]", + "reused": false + } + } + } + } + } + }, + "derived": { + "parsed": { + "kind": "note", + "note": { + "kind": "fabro.checkpoint", + "payload": { + "execution": 0, + "firing": 1, + "attempt": 1, + "workspace": "invocation-0-scope-0", + "git_commit_sha": "[DIGEST]", + "reused": false + } + } + } + } + } + }, + { + "run_id": "[ULID]", + "stream_seq": 22, + "kind": "petri", + "id": "execution 0/8/0", + "recorded_at": "[EPOCH_MS]", + "item": { + "id": { + "log": "execution", + "execution": 0, + "seq": 8, + "index": 0 + }, + "origin": "external", + "context": { + "invocation": 0, + "execution": 0 + }, + "subject": { + "node": { + "id": 0, + "name": "start", + "kind": "attractor/stage", + "meta": { + "label": "Start", + "shape": "Mdiamond", + "kind": "start", + "classes": [], + "span": { + "line": 3, + "column": 3 + }, + "admission_hooks": "step", + "edges": { + "0": { + "to": "approve", + "label": null + } + } + } + }, + "firing": 1, + "visit": 1, + "attempt": 1, + "generation": 0, + "branch": { + "role": "none" + } + }, + "recorded_at": "[EPOCH_MS]", + "record": { + "seq": 8, + "origin": "external", + "recorded_at": "[EPOCH_MS]", "body": { "event": "step.finished", "firing": 1, @@ -1664,15 +1700,15 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "run_id": "[ULID]", - "stream_seq": 22, + "stream_seq": 23, "kind": "petri", - "id": "execution 0/7/1", + "id": "execution 0/8/1", "recorded_at": "[EPOCH_MS]", "item": { "id": { "log": "execution", "execution": 0, - "seq": 7, + "seq": 8, "index": 1 }, "origin": "derived", @@ -1735,7 +1771,7 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "run_id": "[ULID]", - "stream_seq": 23, + "stream_seq": 24, "kind": "platform", "id": "[EVENT_ID]", "recorded_at": "[EPOCH_MS]", @@ -1768,15 +1804,15 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "run_id": "[ULID]", - "stream_seq": 24, + "stream_seq": 25, "kind": "petri", - "id": "execution 0/8/0", + "id": "execution 0/9/0", "recorded_at": "[EPOCH_MS]", "item": { "id": { "log": "execution", "execution": 0, - "seq": 8, + "seq": 9, "index": 0 }, "origin": "external", @@ -1817,7 +1853,7 @@ fn attach_json_errors_without_prompting_for_human_input() { }, "recorded_at": "[EPOCH_MS]", "record": { - "seq": 8, + "seq": 9, "origin": "external", "recorded_at": "[EPOCH_MS]", "body": { @@ -1876,15 +1912,15 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "run_id": "[ULID]", - "stream_seq": 25, + "stream_seq": 26, "kind": "petri", - "id": "execution 0/8/1", + "id": "execution 0/9/1", "recorded_at": "[EPOCH_MS]", "item": { "id": { "log": "execution", "execution": 0, - "seq": 8, + "seq": 9, "index": 1 }, "origin": "derived", @@ -1945,15 +1981,15 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "run_id": "[ULID]", - "stream_seq": 26, + "stream_seq": 27, "kind": "petri", - "id": "execution 0/8/2", + "id": "execution 0/9/2", "recorded_at": "[EPOCH_MS]", "item": { "id": { "log": "execution", "execution": 0, - "seq": 8, + "seq": 9, "index": 2 }, "origin": "derived", @@ -2002,99 +2038,6 @@ fn attach_json_errors_without_prompting_for_human_input() { } } }, - { - "run_id": "[ULID]", - "stream_seq": 27, - "kind": "petri", - "id": "execution 0/9/0", - "recorded_at": "[EPOCH_MS]", - "item": { - "id": { - "log": "execution", - "execution": 0, - "seq": 9, - "index": 0 - }, - "origin": "core", - "context": { - "invocation": 0, - "execution": 0 - }, - "subject": { - "node": { - "id": 0, - "name": "start", - "kind": "attractor/stage", - "meta": { - "label": "Start", - "shape": "Mdiamond", - "kind": "start", - "classes": [], - "span": { - "line": 3, - "column": 3 - }, - "admission_hooks": "step", - "edges": { - "0": { - "to": "approve", - "label": null - } - } - } - }, - "firing": 1, - "visit": 1, - "attempt": 1, - "generation": 0, - "branch": { - "role": "none" - } - }, - "recorded_at": "[EPOCH_MS]", - "record": { - "seq": 9, - "origin": "core", - "recorded_at": "[EPOCH_MS]", - "body": { - "event": "route.applied", - "kind": "edge", - "firing": 1, - "group": 0, - "edge": 0 - } - }, - "derived": { - "target": { - "id": 2, - "name": "approve", - "kind": "attractor/human", - "meta": { - "label": "Approve?", - "shape": "hexagon", - "kind": "human", - "classes": [], - "span": { - "line": 5, - "column": 3 - }, - "edges": { - "1": { - "to": "ship", - "label": "[A] Approve" - }, - "2": { - "to": "revise", - "label": "[R] Revise" - } - } - } - }, - "transition": "Continue", - "back": false - } - } - }, { "run_id": "[ULID]", "stream_seq": 28, @@ -2150,38 +2093,15 @@ fn attach_json_errors_without_prompting_for_human_input() { "origin": "core", "recorded_at": "[EPOCH_MS]", "body": { - "event": "token.emitted", - "edge": 0, - "generation": 0, - "payload": { - "outcome": "succeeded", - "failure_class": "" - }, - "from": 1 + "event": "route.applied", + "kind": "edge", + "firing": 1, + "group": 0, + "edge": 0 } - } - } - }, - { - "run_id": "[ULID]", - "stream_seq": 29, - "kind": "petri", - "id": "execution 0/11/0", - "recorded_at": "[EPOCH_MS]", - "item": { - "id": { - "log": "execution", - "execution": 0, - "seq": 11, - "index": 0 }, - "origin": "external", - "context": { - "invocation": 0, - "execution": 0 - }, - "subject": { - "node": { + "derived": { + "target": { "id": 2, "name": "approve", "kind": "attractor/human", @@ -2206,7 +2126,53 @@ fn attach_json_errors_without_prompting_for_human_input() { } } }, - "firing": 2, + "transition": "Continue", + "back": false + } + } + }, + { + "run_id": "[ULID]", + "stream_seq": 29, + "kind": "petri", + "id": "execution 0/11/0", + "recorded_at": "[EPOCH_MS]", + "item": { + "id": { + "log": "execution", + "execution": 0, + "seq": 11, + "index": 0 + }, + "origin": "core", + "context": { + "invocation": 0, + "execution": 0 + }, + "subject": { + "node": { + "id": 0, + "name": "start", + "kind": "attractor/stage", + "meta": { + "label": "Start", + "shape": "Mdiamond", + "kind": "start", + "classes": [], + "span": { + "line": 3, + "column": 3 + }, + "admission_hooks": "step", + "edges": { + "0": { + "to": "approve", + "label": null + } + } + } + }, + "firing": 1, "visit": 1, "attempt": 1, "generation": 0, @@ -2217,18 +2183,17 @@ fn attach_json_errors_without_prompting_for_human_input() { "recorded_at": "[EPOCH_MS]", "record": { "seq": 11, - "origin": "external", + "origin": "core", "recorded_at": "[EPOCH_MS]", "body": { - "event": "admission.decided", - "decision_id": { - "attempt_start": { - "firing": 2, - "attempt": 1 - } + "event": "token.emitted", + "edge": 0, + "generation": 0, + "payload": { + "outcome": "succeeded", + "failure_class": "" }, - "decision": "admit", - "trace": [] + "from": 1 } } } @@ -2291,9 +2256,15 @@ fn attach_json_errors_without_prompting_for_human_input() { "origin": "external", "recorded_at": "[EPOCH_MS]", "body": { - "event": "step.started", - "firing": 2, - "attempt": 1 + "event": "admission.decided", + "decision_id": { + "attempt_start": { + "firing": 2, + "attempt": 1 + } + }, + "decision": "admit", + "trace": [] } } } @@ -2302,65 +2273,6 @@ fn attach_json_errors_without_prompting_for_human_input() { "run_id": "[ULID]", "stream_seq": 31, "kind": "petri", - "id": "execution 0/12/1", - "recorded_at": "[EPOCH_MS]", - "item": { - "id": { - "log": "execution", - "execution": 0, - "seq": 12, - "index": 1 - }, - "origin": "derived", - "context": { - "invocation": 0, - "execution": 0 - }, - "subject": { - "node": { - "id": 2, - "name": "approve", - "kind": "attractor/human", - "meta": { - "label": "Approve?", - "shape": "hexagon", - "kind": "human", - "classes": [], - "span": { - "line": 5, - "column": 3 - }, - "edges": { - "1": { - "to": "ship", - "label": "[A] Approve" - }, - "2": { - "to": "revise", - "label": "[R] Revise" - } - } - } - }, - "firing": 2, - "visit": 1, - "attempt": 1, - "generation": 0, - "branch": { - "role": "none" - } - }, - "recorded_at": "[EPOCH_MS]", - "derived": { - "event": "wait.state.changed", - "state": "running" - } - } - }, - { - "run_id": "[ULID]", - "stream_seq": 32, - "kind": "petri", "id": "execution 0/13/0", "recorded_at": "[EPOCH_MS]", "item": { @@ -2415,58 +2327,16 @@ fn attach_json_errors_without_prompting_for_human_input() { "origin": "external", "recorded_at": "[EPOCH_MS]", "body": { - "event": "step.progress.recorded", + "event": "step.started", "firing": 2, - "ev": { - "custom": { - "$question": { - "id": "approve#2", - "text": "Approve?", - "options": [ - { - "key": "A", - "label": "[A] Approve" - }, - { - "key": "R", - "label": "[R] Revise" - } - ], - "default": "A", - "freeform": false, - "sensitive": false - } - } - } - } - }, - "derived": { - "parsed": { - "kind": "question", - "question": { - "id": "approve#2", - "text": "Approve?", - "options": [ - { - "key": "A", - "label": "[A] Approve" - }, - { - "key": "R", - "label": "[R] Revise" - } - ], - "default": "A", - "freeform": false, - "sensitive": false - } + "attempt": 1 } } } }, { "run_id": "[ULID]", - "stream_seq": 33, + "stream_seq": 32, "kind": "petri", "id": "execution 0/13/1", "recorded_at": "[EPOCH_MS]", @@ -2519,13 +2389,13 @@ fn attach_json_errors_without_prompting_for_human_input() { "recorded_at": "[EPOCH_MS]", "derived": { "event": "wait.state.changed", - "state": "awaiting_answer" + "state": "running" } } }, { "run_id": "[ULID]", - "stream_seq": 34, + "stream_seq": 33, "kind": "petri", "id": "execution 0/14/0", "recorded_at": "[EPOCH_MS]", @@ -2580,6 +2450,172 @@ fn attach_json_errors_without_prompting_for_human_input() { "seq": 14, "origin": "external", "recorded_at": "[EPOCH_MS]", + "body": { + "event": "step.progress.recorded", + "firing": 2, + "ev": { + "custom": { + "$question": { + "id": "approve#2", + "text": "Approve?", + "options": [ + { + "key": "A", + "label": "[A] Approve" + }, + { + "key": "R", + "label": "[R] Revise" + } + ], + "default": "A", + "freeform": false, + "sensitive": false + } + } + } + } + }, + "derived": { + "parsed": { + "kind": "question", + "question": { + "id": "approve#2", + "text": "Approve?", + "options": [ + { + "key": "A", + "label": "[A] Approve" + }, + { + "key": "R", + "label": "[R] Revise" + } + ], + "default": "A", + "freeform": false, + "sensitive": false + } + } + } + } + }, + { + "run_id": "[ULID]", + "stream_seq": 34, + "kind": "petri", + "id": "execution 0/14/1", + "recorded_at": "[EPOCH_MS]", + "item": { + "id": { + "log": "execution", + "execution": 0, + "seq": 14, + "index": 1 + }, + "origin": "derived", + "context": { + "invocation": 0, + "execution": 0 + }, + "subject": { + "node": { + "id": 2, + "name": "approve", + "kind": "attractor/human", + "meta": { + "label": "Approve?", + "shape": "hexagon", + "kind": "human", + "classes": [], + "span": { + "line": 5, + "column": 3 + }, + "edges": { + "1": { + "to": "ship", + "label": "[A] Approve" + }, + "2": { + "to": "revise", + "label": "[R] Revise" + } + } + } + }, + "firing": 2, + "visit": 1, + "attempt": 1, + "generation": 0, + "branch": { + "role": "none" + } + }, + "recorded_at": "[EPOCH_MS]", + "derived": { + "event": "wait.state.changed", + "state": "awaiting_answer" + } + } + }, + { + "run_id": "[ULID]", + "stream_seq": 35, + "kind": "petri", + "id": "execution 0/15/0", + "recorded_at": "[EPOCH_MS]", + "item": { + "id": { + "log": "execution", + "execution": 0, + "seq": 15, + "index": 0 + }, + "origin": "external", + "context": { + "invocation": 0, + "execution": 0 + }, + "subject": { + "node": { + "id": 2, + "name": "approve", + "kind": "attractor/human", + "meta": { + "label": "Approve?", + "shape": "hexagon", + "kind": "human", + "classes": [], + "span": { + "line": 5, + "column": 3 + }, + "edges": { + "1": { + "to": "ship", + "label": "[A] Approve" + }, + "2": { + "to": "revise", + "label": "[R] Revise" + } + } + } + }, + "firing": 2, + "visit": 1, + "attempt": 1, + "generation": 0, + "branch": { + "role": "none" + } + }, + "recorded_at": "[EPOCH_MS]", + "record": { + "seq": 15, + "origin": "external", + "recorded_at": "[EPOCH_MS]", "body": { "event": "step.progress.recorded", "firing": 2, diff --git a/lib/apps/fabro-cli/tests/it/cmd/sandbox_cp.rs b/lib/apps/fabro-cli/tests/it/cmd/sandbox_cp.rs index e79bffbd9..03052a3d0 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/sandbox_cp.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/sandbox_cp.rs @@ -3,9 +3,14 @@ reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path" )] -use fabro_test::{fabro_snapshot, test_context}; +use std::path::Path; -use super::support::{read_text, setup_local_sandbox_run, setup_seeded_created_dry_run, text_tree}; +use fabro_test::{fabro_snapshot, test_context}; +use fabro_types::RunSandbox; + +use super::support::{ + read_text, run_state, setup_local_sandbox_run, setup_seeded_created_dry_run, text_tree, +}; #[test] fn help() { @@ -113,8 +118,15 @@ fn sandbox_cp_uploads_file_to_run() { ----- stdout ----- ----- stderr ----- "); + // The run executes in its own workspace, not in the target folder: the + // upload lands where the run's sandbox works. + let working_directory = run_state(&setup.run.run_dir) + .sandbox + .and_then(RunSandbox::into_instance) + .map(|instance| instance.runtime.working_directory) + .expect("the run's sandbox instance"); assert_eq!( - read_text(&setup.workspace_dir.join("sandbox_dir/uploaded.txt")), + read_text(&Path::new(&working_directory).join("sandbox_dir/uploaded.txt")), "uploaded-root" ); } diff --git a/lib/apps/fabro-cli/tests/it/cmd/support.rs b/lib/apps/fabro-cli/tests/it/cmd/support.rs index fb4d73002..1cc6a56ba 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/support.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/support.rs @@ -58,9 +58,11 @@ pub(crate) struct ProjectFixture { pub(crate) fabro_root: PathBuf, } +/// A run whose workflow populated its sandbox. The run executes in its own +/// workspace, not in the target folder, so the sandbox's files are read +/// back through the run. pub(crate) struct WorkspaceRunSetup { - pub(crate) run: RunSetup, - pub(crate) workspace_dir: PathBuf, + pub(crate) run: RunSetup, } pub(crate) struct WorkflowGate { @@ -542,7 +544,7 @@ provider = "local" let run = run_local_workflow(context, &workspace_dir, "workflow.toml"); assert!(run_state(&run.run_dir).sandbox.is_some()); - WorkspaceRunSetup { run, workspace_dir } + WorkspaceRunSetup { run } } fn run_local_workflow(context: &TestContext, workspace_dir: &Path, workflow: &str) -> RunSetup { diff --git a/lib/apps/fabro-server/src/server/handler/events.rs b/lib/apps/fabro-server/src/server/handler/events.rs index 2a452d63d..7b0083461 100644 --- a/lib/apps/fabro-server/src/server/handler/events.rs +++ b/lib/apps/fabro-server/src/server/handler/events.rs @@ -8,6 +8,7 @@ use std::time::Duration; use fabro_api::types::PaginatedRunStreamList; use fabro_petri::petri::EVENT_CONTRACT_VERSION; +use fabro_redact::redact_json_value; use fabro_types::RunStreamItem; use tokio::sync::broadcast::error::RecvError; use tokio::time::{self, Instant}; @@ -154,6 +155,12 @@ async fn list_run_stream(state: &AppState, id: RunId, after: u64, limit: usize) Ok(mut items) => { let has_more = items.len() > limit; items.truncate(limit); + // The same items the attached stream serves, redacted the same + // way, so a client that pages the listing after a stream sees + // what the stream showed. + for item in &mut items { + item.item = redact_json_value(std::mem::take(&mut item.item)); + } Json(PaginatedRunStreamList { data: items, meta: PaginationMeta { diff --git a/lib/apps/fabro-server/tests/it/scenario/petri.rs b/lib/apps/fabro-server/tests/it/scenario/petri.rs index c283d7626..143fde2ec 100644 --- a/lib/apps/fabro-server/tests/it/scenario/petri.rs +++ b/lib/apps/fabro-server/tests/it/scenario/petri.rs @@ -20,7 +20,8 @@ use std::collections::BTreeMap; use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::Arc; use axum::body::Body; @@ -46,6 +47,8 @@ use crate::helpers::{ const HOST_PLUGIN: &str = "sandbox-driver-host"; const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN"; +const DOCKER_PLUGIN: &str = "sandbox-driver-docker"; +const DOCKER_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_DOCKER_PLUGIN"; const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS"; const OPENAI_MODEL: &str = "gpt-5.4"; @@ -127,6 +130,38 @@ pub(super) fn host_plugin() -> Option { found } +/// The Docker plugin as Petri's lookup finds it, with a daemon that +/// answers. `None`, after saying so, when the test should skip; a panic +/// when the environment forbids a skip and the plugin is missing. +fn docker_plugin() -> Option { + let found = env::var_os(DOCKER_PLUGIN_OVERRIDE) + .map(PathBuf::from) + .or_else(|| { + env::split_paths(&env::var_os("PATH")?) + .map(|dir| dir.join(DOCKER_PLUGIN)) + .find(|candidate| candidate.is_file()) + }); + let Some(found) = found else { + assert!( + env::var_os(REQUIRE_ENV).is_none(), + "{REQUIRE_ENV} is set, but {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} is unset" + ); + eprintln!("skipping: {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} is unset"); + return None; + }; + let daemon = Command::new("docker") + .args(["version", "--format", "{{.Server.Version}}"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if !daemon { + eprintln!("skipping: no Docker daemon answers"); + return None; + } + Some(found) +} + /// Register a version whose entrypoint is `workflow.fabro`, with the given /// files beside it. pub(super) async fn register_version(app: &axum::Router, files: &[(&str, &str)]) -> String { @@ -652,3 +687,177 @@ async fn a_human_gate_is_answered_through_the_questions_api() { ); super::petri_stream::capture_settled(&state, &app, &run_id, "gate").await; } + +/// The sandbox a run executed in, as its projection carries it from Petri's +/// `scope.acquired`: the run's own scope on the host provider, ready, with +/// the directory id a reconnect attaches by and the working directory the +/// steps ran in. The summary carries the same instance, so Ask Fabro and +/// `sandbox cp` reach the sandbox after the run. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_runs_projection_carries_its_host_sandbox_instance() { + if host_plugin().is_none() { + return; + } + let workspace = tempfile::tempdir().expect("workspace tempdir"); + let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); + let state = test_app_state_with_options(settings, 5); + let app = test_app_with_scheduler(Arc::clone(&state)); + + let version_id = register_version(&app, &[ + ("workflow.fabro", COMMAND_DOT), + ("workflow.toml", PLAIN_SETTINGS), + ]) + .await; + let run_id = + create_and_start_run_from_intent(&app, intent(&version_id, workspace.path())).await; + let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await; + assert_eq!( + status, + "succeeded", + "run: {}", + run_json(&app, &run_id).await + ); + + let projection = settled_state(&state, &app, &run_id).await; + let sandbox = &projection["sandbox"]; + assert_eq!(sandbox["kind"], "ready", "{sandbox}"); + assert_eq!(sandbox["plan"]["provider"], "local", "{sandbox}"); + let instance = &sandbox["instance"]; + assert_eq!(instance["provider"], "local", "{instance}"); + assert!( + instance.get("image").is_none(), + "a host directory runs no image: {instance}" + ); + let id = instance["runtime"]["id"] + .as_str() + .expect("the provider's id for the sandbox"); + assert!( + id.starts_with("host-"), + "the host provider's id for the workspace directory: {id}" + ); + let working_directory = instance["runtime"]["working_directory"] + .as_str() + .expect("the working directory"); + assert!( + Path::new(working_directory).is_dir(), + "the workspace is retained after the run: {working_directory}" + ); + assert!(sandbox.get("failure").is_none(), "{sandbox}"); + + let run = run_json(&app, &run_id).await; + assert_eq!(run["sandbox"]["kind"], "ready", "{run}"); + assert_eq!(run["sandbox"]["instance"]["runtime"]["id"], id, "{run}"); +} + +/// The same on the Docker provider: the instance is the run's container, +/// with the image it runs and the container's workspace, so a reconnect +/// attaches to it on the daemon. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_runs_projection_carries_its_docker_sandbox_instance() { + if docker_plugin().is_none() { + return; + } + let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"docker\"\n"); + let state = test_app_state_with_options(settings, 5); + let app = test_app_with_scheduler(Arc::clone(&state)); + // A Docker environment on the daemon's default runner image. + let environment = serde_json::json!({ + "id": "docker", + "provider": "docker", + "image": { "docker": null, "dockerfile": null }, + "resources": { "cpu": null, "memory": null, "disk": null }, + "network": { "mode": "allow_all", "allow": [] }, + "lifecycle": { "preserve": false, "stop_on_terminal": true, "auto_stop": null }, + "labels": {}, + "env": {} + }); + let request = Request::builder() + .method("POST") + .uri(api("/environments")) + .header("content-type", "application/json") + .body(Body::from(environment.to_string())) + .expect("environment request should build"); + let response = app + .clone() + .oneshot(request) + .await + .expect("environment request routes"); + response_json( + response, + StatusCode::CREATED, + "POST /api/v1/environments".to_string(), + ) + .await; + + let version_id = register_version(&app, &[ + ("workflow.fabro", COMMAND_DOT), + ("workflow.toml", PLAIN_SETTINGS), + ]) + .await; + // A Docker environment takes no folder target: the workspace is the + // container's own. + let intent = serde_json::json!({ + "workflow_version_id": version_id, + "target": {"kind": "none"}, + "environment_id": "docker", + "args": {}, + }); + let run_id = create_and_start_run_from_intent(&app, intent).await; + let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await; + assert_eq!( + status, + "succeeded", + "run: {}", + run_json(&app, &run_id).await + ); + + let projection = settled_state(&state, &app, &run_id).await; + let sandbox = &projection["sandbox"]; + assert_eq!(sandbox["kind"], "ready", "{sandbox}"); + let instance = &sandbox["instance"]; + assert_eq!(instance["provider"], "docker", "{instance}"); + assert!( + instance["image"] + .as_str() + .is_some_and(|image| !image.is_empty()), + "the image the container runs: {instance}" + ); + let id = instance["runtime"]["id"] + .as_str() + .expect("the container id"); + assert!(!id.is_empty(), "{instance}"); + assert_eq!( + instance["runtime"]["working_directory"], "/workspace", + "{instance}" + ); + + // The container is on the daemon, under Petri's run label. + let output = Command::new("docker") + .args([ + "ps", + "-aq", + "--filter", + &format!("label=petri.run={run_id}"), + ]) + .output() + .expect("docker ps runs"); + let containers: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect(); + assert!( + containers + .iter() + .any(|container| id.starts_with(container.as_str())), + "the recorded instance is the run's container: {id} in {containers:?}" + ); + for container in &containers { + let _ = Command::new("docker") + .args(["rm", "-f", container]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} diff --git a/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs b/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs index a77050852..94844f1b1 100644 --- a/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs +++ b/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs @@ -111,7 +111,7 @@ pub(super) async fn list_stream( .expect("has_more is a bool"); assert_eq!( page["event_contract_version"].as_u64(), - Some(3), + Some(u64::from(fabro_petri::petri::EVENT_CONTRACT_VERSION)), "the server reports Petri's contract version: {page}" ); let Some(last) = data.last() else { diff --git a/lib/components/fabro-petri/README.md b/lib/components/fabro-petri/README.md index ccfd36793..67207891b 100644 --- a/lib/components/fabro-petri/README.md +++ b/lib/components/fabro-petri/README.md @@ -104,8 +104,11 @@ yet, keep their default value in the projection: `StageProjection.diff` and `Checkpoint`'s engine-derived maps (`completed_nodes`, `node_retries`, `context_values`, `node_outcomes`, `next_node_id`), `agent_tools`, `permission_level`, `script_invocation` and `script_timing`, a stage's -`notes`, `StageCompletion` details for a `parsed.note`, the sandbox instance -(the matrix's two gaps), `Run.ask_fabro`, an interview option's +`notes`, `StageCompletion` details for a `parsed.note`, the sandbox +instance's clone fields and workspace roots (Petri's checkout is a copy of +the bound repository, not a clone; the roots are the provider's, read live) +and the retention outcome (kept as `FoldState.sandbox_retained`; the view +has no field for it), `Run.ask_fabro`, an interview option's `description` and `preview`, the pull request `creation` state, and the run's notices, notifications and pairings (recorded, not shown). diff --git a/lib/components/fabro-petri/VIEWS.md b/lib/components/fabro-petri/VIEWS.md index 824269921..ff77addfc 100644 --- a/lib/components/fabro-petri/VIEWS.md +++ b/lib/components/fabro-petri/VIEWS.md @@ -198,18 +198,25 @@ the CLI setup lines. Under the plan Petri acquires every scope through the sandbox-driver plugin, labels it with the run key, and decides retention (`Always` is the Fabro -default). Petri records the binding and nothing else durable about the -instance: the acquisition progress lines are terminal-only, and the -`ScopeReady` hook payload (`scope`, `workspace`) reaches a durable note only -when a `sandbox_ready` hook ran. +default). Petri records the binding, the instance and the retention outcome: +`scope.acquired` (an engine record, once per acquisition, before any attempt +in the scope) carries the provider, the provider's id for the sandbox, its +image and snapshot when the provider knows them, the working directory, the +workspace and lease, and the acquisition time; `scope.failed` the error, its +causes and the reserved provider; `scope.released` (a coordinator record, +once per lease the invocation owned, before `run.finished`) the outcome +retention read, whether the sandbox still exists, and any release problem. +The run's sandbox is the root invocation's scope; a child invocation's scope +(a parallel branch) is not the run's. Petri names the host provider `host`, +which is Fabro's `local`; every other kind is spelled the same. | Fabro fact | Fields | Source | Keyed on | | --- | --- | --- | --- | | plan | `RunSandbox.plan {provider, image, snapshot}` | `graph.registered`'s `fabro.environment` and `fabro.launch {sandbox_backend}` params; platform record `run.created` | run | | binding: isolated or inherited | none today | `invocation.declared {sandbox}` | invocation | -| which: planned, initializing, ready, failed | `RunSandbox.kind`, `sandbox.initializing`, `sandbox.ready {duration_ms, name, url}`, `sandbox.failed {error, causes, duration_ms}` | gap: proposed Petri record `scope.acquired`, `scope.failed` | scope | -| where: instance id, working directory, clone, workspace roots | `RunSandboxInstance.runtime {id, working_directory, repo_cloned, clone_origin_url, clone_branch, workspace_root, repos_root, primary_repo_path, primary_repo_link}`, `sandbox.initialized` | gap: the same `scope.acquired`; the clone from `custom attractor.checkout` | scope | -| retention | none today; the run-end `sandbox_cleanup` hook | gap: proposed `scope.released {scope, outcome, retained}`; `run.note.recorded {kind: hook, point: scope_released}` when a hook ran | scope | +| which: planned, initializing, ready, failed | `RunSandbox.kind`, `sandbox.initializing`, `sandbox.ready {duration_ms, name, url}`, `sandbox.failed {error, causes, duration_ms}` | `planned` from the platform record `run.created`; `initializing` from `run.started`; `ready` from the root invocation's `scope.acquired` (`provider`, `image`, `snapshot`); `failed` from its `scope.failed` (`provider`, `error`, `causes`, `duration_ms`). The ready duration (`scope.acquired` `duration_ms`) has no field on `RunSandbox` and is not projected | scope | +| where: instance id, working directory, clone, workspace roots | `RunSandboxInstance.runtime {id, working_directory, repo_cloned, clone_origin_url, clone_branch, workspace_root, repos_root, primary_repo_path, primary_repo_link}`, `sandbox.initialized` | `scope.acquired` (`instance` is the id a reconnect attaches by, `working_directory`). The clone fields stay unset: `custom attractor.checkout` records Petri's copy of the bound repository into the workspace (`repository`, `commit`, `depth`, `files`), which is not a clone Fabro made, and the workspace roots are the provider's layout, read live | scope | +| retention | none today; the run-end `sandbox_cleanup` hook | `scope.released {outcome, retained, problems}` of the root invocation's lease, kept as `FoldState.sandbox_retained`; the view has no field for it and `Run.sandbox` keeps naming the instance that ran. `run.note.recorded {kind: hook, point: scope_released}` when a hook ran | scope | | live status, resources, files, services, VNC, preview, SSH | `SandboxStatus`, `SandboxFileEntry`, `SandboxService`, `VncPreviewResponse`, `PreviewUrlResponse`, `SshAccessResponse`, `ssh.ready` | live: the sandbox-driver provider queried by the run label | run | | setup commands | `setup.started`, `setup.command.completed`, `setup.completed`, `setup.failed`, `cli.ensure.*` | the `run_prepare_N` stages (Stages section); `cli.ensure.*` has no Petri equivalent and is dropped (the image carries the CLI) | stage | @@ -417,8 +424,6 @@ record where Fabro does. | Fact | Views | Smallest source | | --- | --- | --- | -| sandbox instance: provider, instance id, image, snapshot, working directory, workspace roots, duration, failure | `Run.sandbox`, the Sandbox tab, `sandbox.*` CLI lines, `runs inspect`, `ask_fabro` | a Petri engine record `scope.acquired {scope, provider, instance, image, snapshot, workspace, duration_ms}` and `scope.failed {scope, provider, error, causes, duration_ms}`, appended by the driver where it fires `ScopeReady`; today the facts are terminal-only progress lines. Fallback: a platform record `sandbox.ready` written from Fabro's forwarded `ScopeReady` hook, which carries only `scope` and `workspace` | -| retention outcome | `sandbox_cleanup`, the sandbox tab after the run | a Petri record `scope.released {scope, outcome, retained}` where the driver fires `ScopeReleased`; today only a `run.note.recorded` exists, and only when a hook ran | | tools available to an agent | `agent_tools`, the insights sidebar's tool list | a `custom attractor.tools {node, firing, attempt, session, tools[] {name, description, source, category}}` from the native backend once per session, where it calls the `HostTools` builders; Pebble's `SessionStarted` carries only the provider and model | | question option `description` and `preview`, `context_display` | the interview dock, the human Q&A renderer | optional fields on Petri's `QuestionOption` (`description`, `preview`) and `Question` (`context`), set by the human gate from the edge attributes Fabro's lowering already reads | | who answered | `interview.completed` `actor`, Slack attribution | platform record `interview.answered {question, principal, channel}` written by Fabro's interviewer beside its `InterviewReply` | diff --git a/lib/components/fabro-petri/src/projection.rs b/lib/components/fabro-petri/src/projection.rs index bf910fdd6..a26223789 100644 --- a/lib/components/fabro-petri/src/projection.rs +++ b/lib/components/fabro-petri/src/projection.rs @@ -37,17 +37,18 @@ use fabro_types::{ FailureCategory, FailureDetail, FailureReason, InterviewOption, InterviewQuestionRecord, ModelRef, ModelUsage, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord, PullRequestCreation, PullRequestCreationStatus, PullRequestLink, RunApproval, RunApprovalState, - RunControlAction, RunDiff, RunFailure, RunId, RunProjection, RunSandbox, RunSandboxPlan, - RunStatus, RunTiming, SandboxProviderKind, StageCompletion, StageHandler, StageId, - StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState, - StageTiming, StartRecord, SuccessReason, first_event_seq, timing, usage_rollup, + RunControlAction, RunDiff, RunFailure, RunId, RunProjection, RunSandbox, RunSandboxFailure, + RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, RunStatus, RunTiming, + SandboxProviderKind, StageCompletion, StageHandler, StageId, StageInferenceProjection, + StageModelUsage, StageOutcome, StageProjection, StageState, StageTiming, StartRecord, + SuccessReason, first_event_seq, timing, usage_rollup, }; use lithos_llm::catalog::{ModelId, ProviderId}; use lithos_llm::types::Usage; use petri_execution::events::{Derived, NodeRef, Parsed, RunEvent, Subject, ViewEvent, WaitState}; -use petri_execution::{CoordinatorEvent, ExecutionId}; +use petri_execution::{CoordinatorEvent, ExecutionId, InvocationId}; use petri_runtime::engine::{Admission, Event}; -use petri_runtime::ir::{Metrics, Status, StepEvent}; +use petri_runtime::ir::{Metrics, SandboxInstance, Status, StepEvent}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tracing::debug; @@ -136,6 +137,11 @@ pub struct FoldState { /// behind. #[serde(default)] pub finished_firings: BTreeSet, + /// Whether the run's sandbox still exists after its release + /// (`scope.released` `retained`): kept stopped, or deleted. Absent until + /// the root invocation's lease was released. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_retained: Option, } impl FoldState { @@ -338,6 +344,11 @@ impl RunView { run_branch: self.state.run_branch.clone(), base_sha: self.state.base_sha.clone(), }); + // The scope's sandbox is acquired next; `scope.acquired` + // or `scope.failed` settles it. + if let Some(sandbox) = projection.sandbox.take() { + projection.sandbox = Some(RunSandbox::initializing(sandbox.plan().clone())); + } } } CoordinatorEvent::InvocationDeclared { invocation, .. } => { @@ -404,6 +415,19 @@ impl RunView { self.state.finished = Some(status.to_string()); self.conclude(status.to_string().as_str(), at); } + // ── Sandbox: the retention outcome (VIEWS.md "Sandbox") ───────── + // The view has no retention field; the fact is kept in the fold + // state for the read side. The instance stays on `Run.sandbox`: + // it names what ran, whether or not it still exists. + CoordinatorEvent::ScopeReleased { + invocation, + retained, + .. + } => { + if Some(invocation.raw()) == self.state.root { + self.state.sandbox_retained = Some(*retained); + } + } CoordinatorEvent::GraphRegistered { .. } | CoordinatorEvent::ExecutionFinished { .. } | CoordinatorEvent::InvocationCancelRequested { .. } @@ -603,6 +627,41 @@ impl RunView { self.close_questions(answer.question.as_deref(), firing_key.as_deref(), at); } } + // ── Sandbox: the instance (VIEWS.md "Sandbox") ────────────────── + // The run's sandbox is the root invocation's scope. A child + // invocation's scope (a parallel branch) shares or owns another + // one and is not the run's; a re-acquisition (a resume, a + // replaced sandbox) names the current instance. + Event::ScopeAcquired { sandbox, .. } => { + if let Some(projection) = self.root_scope_projection(event) { + let plan = sandbox_plan_of(projection); + projection.sandbox = Some(RunSandbox::ready( + plan.clone(), + sandbox_instance(&plan, sandbox), + )); + } + } + Event::ScopeFailed { + provider, + error, + causes, + duration_ms, + .. + } => { + if let Some(projection) = self.root_scope_projection(event) { + let plan = sandbox_plan_of(projection); + let provider = provider + .as_deref() + .and_then(provider_kind) + .unwrap_or_else(|| plan.provider.clone()); + projection.sandbox = Some(RunSandbox::failed(plan, RunSandboxFailure { + provider: provider.to_string(), + error: error.clone(), + causes: causes.clone(), + duration_ms: *duration_ms, + })); + } + } Event::ExecutionStarted { .. } | Event::TokenEmitted { .. } | Event::RoutingResolved { .. } @@ -614,6 +673,16 @@ impl RunView { } } + /// The projection, when `event` is a scope record of the root + /// invocation: the run's own sandbox, not a child invocation's. + fn root_scope_projection(&mut self, event: &RunEvent) -> Option<&mut RunProjection> { + let root = self.state.root?; + if event.context.invocation.map(InvocationId::raw) != Some(root) { + return None; + } + self.projection.as_mut() + } + fn fold_progress( &mut self, execution: ExecutionId, @@ -1349,6 +1418,55 @@ fn sandbox_plan(settings: &RunEnvironmentSettings) -> RunSandboxPlan { } } +/// The plan the projection's sandbox carries, or the one its environment +/// settings give when no sandbox was projected yet. +fn sandbox_plan_of(projection: &RunProjection) -> RunSandboxPlan { + projection.sandbox.as_ref().map_or_else( + || sandbox_plan(&projection.spec.settings.run.environment), + |sandbox| sandbox.plan().clone(), + ) +} + +/// Fabro's name for the provider Petri's `scope.acquired` names: Petri's +/// `host` is Fabro's `local`; every other kind is spelled the same. `None` +/// for a name that is no provider kind. +fn provider_kind(provider: &str) -> Option { + if provider == "host" { + return Some(SandboxProviderKind::LOCAL); + } + SandboxProviderKind::try_new(provider).ok() +} + +/// The run's sandbox instance from Petri's record of the scope's +/// acquisition: the provider, the provider's id for the sandbox (what a +/// reconnect attaches by), its image and snapshot when the provider knows +/// them, and the working directory. The clone fields stay unset: Petri's +/// checkout copies the bound repository into the workspace and is not a +/// clone Fabro made, and the workspace roots are the provider's own layout, +/// read live. +fn sandbox_instance(plan: &RunSandboxPlan, sandbox: &SandboxInstance) -> RunSandboxInstance { + RunSandboxInstance { + provider: provider_kind(&sandbox.provider).unwrap_or_else(|| plan.provider.clone()), + image: sandbox + .image + .as_ref() + .map(ToString::to_string) + .or_else(|| plan.image.clone()), + snapshot: sandbox.snapshot.as_ref().map(ToString::to_string), + runtime: RunSandboxRuntime { + id: sandbox.instance.to_string(), + working_directory: sandbox.working_directory.to_string(), + repo_cloned: None, + clone_origin_url: None, + clone_branch: None, + workspace_root: None, + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, + }, + } +} + fn stage_outcome(status: &Status) -> StageOutcome { match status { Status::Success => StageOutcome::Succeeded, diff --git a/lib/components/fabro-sandbox/src/provider_sandbox.rs b/lib/components/fabro-sandbox/src/provider_sandbox.rs index a5011a1ad..a9a4f078f 100644 --- a/lib/components/fabro-sandbox/src/provider_sandbox.rs +++ b/lib/components/fabro-sandbox/src/provider_sandbox.rs @@ -115,12 +115,35 @@ pub async fn attach_provider_sandbox( let provider = connect(&kind, access, run_id.as_ref()).await?; let id = SandboxId::try_new(sandbox_id) .map_err(|error| crate::Error::context(format!("Invalid {kind} sandbox id"), error))?; - let handle = provider.attach(&id, events).await.map_err(|error| { - crate::Error::context( - format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"), - error, - ) - })?; + let handle = match provider.attach(&id, events.clone()).await { + Ok(handle) => handle, + // A host sandbox is the directory it designates. An id the host + // provider minted for a long path lives only in the registry of the + // process that created it (a run's Petri worker, say), so a + // reconnect from another process designates the directory again: + // the same workspace, whatever the id. + Err(error) + if kind.bundled() == Some(BundledProvider::Local) + && matches!(error, sandbox_driver::Error::NotFound { .. }) => + { + let spec = DriverSpec::new(SandboxSource::HostDirectory) + .working_directory(working_directory.clone()); + provider.create(&spec, events).await.map_err(|error| { + crate::Error::context( + format!( + "Failed to reconnect {kind} sandbox '{sandbox_id}' at {working_directory}" + ), + error, + ) + })? + } + Err(error) => { + return Err(crate::Error::context( + format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"), + error, + )); + } + }; let status = handle.describe().await?; let workspace = RepoWorkspace::attached( layout_source(&kind), diff --git a/lib/foundation/fabro-test/src/lib.rs b/lib/foundation/fabro-test/src/lib.rs index 0ddafda47..e3345fca3 100644 --- a/lib/foundation/fabro-test/src/lib.rs +++ b/lib/foundation/fabro-test/src/lib.rs @@ -86,6 +86,9 @@ static INSTA_FILTERS: &[(&str, &str)] = &[ ), (r#""nanos"(\s*:\s*)\d+"#, r#""nanos"$1"[NANOS]""#), (r"host-dir-[0-9a-f]+", "host-dir-[HEX]"), + // A local sandbox's registry-minted id (a creation time, a process id + // and a counter), for a directory too long for a path-derived id. + (r"host-g[0-9a-f]+-\d+-\d+", "host-g[ID]"), (r"\\([\w\d])", "/$1"), ];