Merge branch 'petri-integration' into petri-integration-gaps

# Conflicts:
#	lib/components/fabro-petri/README.md
#	lib/components/fabro-petri/src/projection.rs
This commit is contained in:
Bryan Helmkamp 2026-09-18 16:00:51 -04:00
commit 56f7180dbf
No known key found for this signature in database
13 changed files with 850 additions and 432 deletions

30
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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,

View file

@ -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"
);
}

View file

@ -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 {
@ -602,7 +604,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 {

View file

@ -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 {

View file

@ -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<PathBuf> {
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<PathBuf> {
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> = 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();
}
}

View file

@ -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 {

View file

@ -123,9 +123,12 @@ yet, keep their default value in the projection: `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 `description`
and `preview`, the pull request `creation` state, and the run's notices,
`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).
### Retention

View file

@ -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 |
@ -418,8 +425,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` |

View file

@ -38,17 +38,18 @@ use fabro_types::{
ModelRef, ModelUsage, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord,
PullRequestCreation, PullRequestCreationStatus, PullRequestLink, RunApproval, RunApprovalState,
RunArtifact, RunControlAction, RunDiff, RunFailure, RunId, RunProjection, RunSandbox,
RunSandboxPlan, RunStatus, RunTiming, SandboxProviderKind, StageCompletion, StageHandler,
StageId, StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState,
RunSandboxFailure, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, RunStatus,
RunTiming, SandboxProviderKind, StageCompletion, StageHandler, StageId,
StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState,
StageTiming, StartRecord, SuccessReason, first_event_seq, format_blob_ref, parse_blob_ref,
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;
@ -141,6 +142,11 @@ pub struct FoldState {
/// behind.
#[serde(default)]
pub finished_firings: BTreeSet<String>,
/// 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<bool>,
}
impl FoldState {
@ -385,6 +391,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, .. } => {
@ -451,6 +462,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 { .. }
@ -679,6 +703,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 { .. }
@ -690,6 +749,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,
@ -1425,6 +1494,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<SandboxProviderKind> {
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,

View file

@ -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),

View file

@ -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"),
];