Skip to content

feat(shell): add workspace picker control plane - #376

Merged
ytallo merged 1 commit into
mainfrom
fix/console-working-dir-control-plane
Jun 30, 2026
Merged

feat(shell): add workspace picker control plane#376
ytallo merged 1 commit into
mainfrom
fix/console-working-dir-control-plane

Conversation

@ytallo

@ytallo ytallo commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add console-only shell::workspace::{roots,list,validate} APIs for browsing and validating host directories
  • move the console DirectoryPicker from coder::* discovery to the new workspace control plane
  • keep workspace picker APIs out of harness base_dir injection and fallback agent exposure
  • allow trusted absolute base_dir selections outside configured roots while preserving relative escape rejection
  • hide internal base_dir fields from published schemas and update coder schema goldens

Replaces #375 with an independent implementation.

Validation

  • cargo test --manifest-path harness/Cargo.toml workspace_inject --lib
  • cargo test --manifest-path shell/Cargo.toml workspace::tests --lib
  • cargo test --manifest-path shell/Cargo.toml session_scoped_base_dir_outside_roots --lib
  • cargo test --manifest-path shell/Cargo.toml base_dir_outside_host_root --lib
  • cargo test --manifest-path shell/Cargo.toml relative_base_dir_still_cannot_escape_host_root --lib
  • cargo test --manifest-path shell/Cargo.toml --test code_golden_schemas
  • cargo test --manifest-path shell/Cargo.toml --bin shell config_defaults_to_local_config_yaml
  • pnpm --dir console/web test -- DirectoryPicker real-metadata
  • pnpm --dir console/web typecheck

Summary by CodeRabbit

  • New Features

    • Added a workspace picker flow for browsing and selecting available directories.
    • Workspace browsing now shows canonical folder paths and lets you validate a chosen directory before using it.
  • Bug Fixes

    • Improved directory resolution so the closest matching workspace root is selected more reliably.
    • Fixed recent directory handling to keep saved paths stable across symlinks.
    • Better preserves access rules while browsing and using file tools within a selected workspace.

@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jun 30, 2026 2:28am
workers-tech-spec Ready Ready Preview, Comment Jun 30, 2026 2:28am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds three new shell::workspace::* control-plane functions (roots, validate, list) implemented in a new workspace.rs module and registered in shell::main. The console DirectoryPicker is updated to call these instead of coder::info/coder::list-folder. The harness injector excludes workspace functions from session scoping, base_dir is now accepted outside configured host roots across fs and exec backends, and base_dir/cwd fields are removed from all published JSON schemas via #[schemars(skip)].

Changes

Workspace picker and base_dir scoping

Layer / File(s) Summary
shell::workspace types and handlers
shell/src/functions/workspace.rs, shell/src/functions/mod.rs
New module defines WorkspaceRootsResponse, WorkspaceValidateRequest/Response, WorkspaceListRequest/Response, and WorkspaceEntry types. Implements workspace_roots, validate_workspace_path, and list_workspace_dirs with canonicalization, denylist filtering, and sorting. Unit tests cover roots inclusion, validate accept/reject, and listing behavior.
Register workspace functions in shell::main
shell/src/main.rs
register_workspace helper attaches shell::workspace::roots, validate, and list as async console-only operator functions with telemetry recording and error mapping.
PathResolver::session_scoped for coder functions
shell/src/code/path.rs, shell/src/code/functions/mod.rs
New session_scoped method conditionally appends a canonicalized base_dir as an extra allowed root. resolve_in now validates base_dir is absolute. All coder function handlers switched to resolver.session_scoped(req.base_dir.as_deref()).
FS backend: base_dir outside host roots + scoped non-accessible check
shell/src/fs/host.rs, shell/src/fs/mod.rs
confine_base_dir rewritten to accept absolute base_dir outside configured host roots (denylist-only rejection). is_non_accessible_scoped and access_roots helpers added; validate_path_scoped, grep, and sed use scoped protected-path policy. Tests updated to use absolute temp paths.
Exec policy: base_dir outside jail roots
shell/src/exec/policy.rs, shell/src/exec/host.rs
confine_base_dir in exec policy rewritten to honor absolute base_dir outside jail roots with denylist-only rejection. build_overrides call signature updated. Tests updated to use absolute paths.
Harness workspace_inject: exclude shell::workspace from session scoping
harness/src/workspace_inject.rs
is_scoped_function now excludes shell::workspace::*. inject actively strips caller-supplied base_dir when working_dir is absent. Tests verify pass-through for workspace functions and stripping for scoped shell calls.
Hide base_dir from all published JSON schemas
shell/src/code/functions/*.rs, shell/src/fs/mod.rs, shell/src/functions/types.rs, shell/tests/golden/schemas/*.json
#[schemars(skip)] added to base_dir on all coder::* and shell::fs::* request types plus ExecRequest/ExecBgRequest.cwd. Golden schema fixtures updated to remove the fields.
Console DirectoryPicker: switch to shell::workspace RPC
console/web/src/components/chat/DirectoryPicker.tsx, console/web/src/components/chat/DirectoryPicker.test.ts, console/web/src/lib/backend/real.ts, console/web/src/lib/backend/real-metadata.test.ts, console/web/src/hooks/use-shell-status.ts
DirectoryPicker replaces coder::info/coder::list-folder with shell::workspace::roots, list, and validate calls; exports function-id constants; uses canonical res.path from validate. FALLBACK_FUNCTION_POLICY exported and expanded to deny shell::workspace::*.

Sequence Diagram(s)

sequenceDiagram
  participant UI as DirectoryPicker
  participant Harness
  participant Shell as shell worker
  participant workspace as functions::workspace

  UI->>Harness: shell::workspace::roots
  Note over Harness: is_scoped_function=false, pass through
  Harness->>Shell: shell::workspace::roots
  Shell->>workspace: workspace_roots(cfg)
  workspace-->>UI: { roots: [...] }

  UI->>Harness: shell::workspace::list { path }
  Harness->>Shell: shell::workspace::list
  Shell->>workspace: list_workspace_dirs(req, cfg)
  workspace-->>UI: { entries: [{name, path, kind}] }

  UI->>Harness: shell::workspace::validate { path }
  Harness->>Shell: shell::workspace::validate
  Shell->>workspace: validate_workspace_path(req, cfg)
  workspace-->>UI: { path: canonical }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • iii-hq/workers#80: Touches shell/src/functions/mod.rs and shell/src/main.rs registration flow, the same extension points used here to add shell::workspace::* functions.
  • iii-hq/workers#327: Introduced per-session base_dir injection in harness/src/workspace_inject.rs, which this PR directly modifies to exclude shell::workspace::* from scoping.

Suggested reviewers

  • sergiofilhowz
  • andersonleal

Poem

🐇 Hop through the filesystem, one dir at a time,
shell::workspace::roots — now that's a paradigm!
base_dir hides from schema, clean as morning dew,
The harness knows to pass the workspace calls right through.
Three new RPC endpoints, canonicalized and bright —
This bunny's directory picker works just right! 🗂️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding the shell workspace picker control plane.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/console-working-dir-control-plane

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 26 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
console/web/src/components/chat/DirectoryPicker.tsx (1)

167-183: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the canonical res.path after a browse jump.

Line 259 keeps the raw pasted path even though shell::workspace::list canonicalizes it. Jumping to a symlink or /a/../b can then list one directory while use this folder and go up act on another. Promote res.path into path (and root matching) in the browse flow, the same way validateAndSelect() already does for selection.

Proposed fix
-  const loadFolder = useCallback(async (target: string) => {
+  const loadFolder = useCallback(async (target: string): Promise<string | null> => {
     setLoading(true)
     setError(null)
     try {
       const client = await getIiiClient()
       const res = await client.trigger<WorkspaceListResult>(
         WORKSPACE_LIST_FUNCTION_ID,
         {
           path: target,
           page_size: 200,
         },
       )
+      const resolved = res?.path ?? target
       const names = (res?.entries ?? [])
         .filter((e) => e.kind === 'dir')
         .map((e) => e.path)
         .sort((a, b) => a.localeCompare(b))
       setDirs(names)
+      return resolved
     } catch (err) {
       setError(errMsg(err))
       setDirs([])
+      return null
     } finally {
       setLoading(false)
     }
   }, [])
@@
   const jumpTo = useCallback(
     async (raw: string) => {
       const p = raw.trim().replace(/\/+$/, '') || '/'
       setView('browse')
       setQuery('')
       const r = await ensureRoots()
-      const matchedRoot =
-        [...r]
-          .sort((a, b) => b.length - a.length)
-          .find((x) => p === x || p.startsWith(`${x}/`)) ??
-        r[0] ??
-        null
-      setRoot(matchedRoot)
-      setPath(p)
-      await loadFolder(p)
+      const resolved = (await loadFolder(p)) ?? p
+      const matchedRoot =
+        [...r]
+          .sort((a, b) => b.length - a.length)
+          .find((x) => resolved === x || resolved.startsWith(`${x}/`)) ??
+        r[0] ??
+        null
+      setRoot(matchedRoot)
+      setPath(resolved)
     },
     [ensureRoots, loadFolder],
   )

Also applies to: 246-260

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@console/web/src/components/chat/DirectoryPicker.tsx` around lines 167 - 183,
The browse flow in DirectoryPicker is keeping the original pasted target instead
of the canonical path returned by shell::workspace::list, which can desync
listing vs actions like use this folder and go up. Update loadFolder and the
browse/jump handling to use res.path as the authoritative path after a
successful list, and make root matching depend on that canonical path just as
validateAndSelect() already does for selection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@shell/src/code/functions/mod.rs`:
- Around line 294-295: The handlers that call
resolver.session_scoped(req.base_dir.as_deref()) are trusting req.base_dir from
deserialized request payloads, which lets direct coder::* calls widen access
outside configured roots. Fix this by preventing public request structs from
accepting base_dir at all, or by stripping/overwriting it at the registration
boundary before any handler like the resolver/session_scoped paths use it. Keep
the harness-injected base_dir separate from the public request types so only
internal harness code can supply it, and direct invocations always use a safe,
server-controlled value.

In `@shell/src/code/path.rs`:
- Around line 384-395: resolve_in() currently allows a canonicalized regular
file to be used as base_dir, which should be rejected to match confine_base_dir
in shell/src/fs/host.rs and shell/src/exec/policy.rs. Update the resolve_in()
flow to verify the canonicalized base_path is an existing directory before
proceeding with containing_root and relative path joins, and return a
BadInput-style error when base_dir points to a file rather than a directory.

In `@shell/src/fs/host.rs`:
- Around line 420-428: The access_roots helper is dropping parent-directory
context when base_dir_canon is added alone, which can let protected scoped roots
bypass non_accessible_globs checks. Update access_roots so the selected base
directory’s parent is also included in the effective roots (or reject protected
selected roots outright), and verify the protected-path matching path in
shell/src/fs/host.rs still preserves names like .git for access checks.

---

Outside diff comments:
In `@console/web/src/components/chat/DirectoryPicker.tsx`:
- Around line 167-183: The browse flow in DirectoryPicker is keeping the
original pasted target instead of the canonical path returned by
shell::workspace::list, which can desync listing vs actions like use this folder
and go up. Update loadFolder and the browse/jump handling to use res.path as the
authoritative path after a successful list, and make root matching depend on
that canonical path just as validateAndSelect() already does for selection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c789d191-9a28-401a-8e85-ce4e23b3cbcd

📥 Commits

Reviewing files that changed from the base of the PR and between 9168307 and 2dd7fab.

📒 Files selected for processing (32)
  • console/web/src/components/chat/DirectoryPicker.test.ts
  • console/web/src/components/chat/DirectoryPicker.tsx
  • console/web/src/hooks/use-shell-status.ts
  • console/web/src/lib/backend/real-metadata.test.ts
  • console/web/src/lib/backend/real.ts
  • harness/src/workspace_inject.rs
  • shell/src/code/functions/create_file.rs
  • shell/src/code/functions/delete_file.rs
  • shell/src/code/functions/list_folder.rs
  • shell/src/code/functions/mod.rs
  • shell/src/code/functions/move_file.rs
  • shell/src/code/functions/read_file.rs
  • shell/src/code/functions/search.rs
  • shell/src/code/functions/tree.rs
  • shell/src/code/functions/update_file.rs
  • shell/src/code/path.rs
  • shell/src/exec/host.rs
  • shell/src/exec/policy.rs
  • shell/src/fs/host.rs
  • shell/src/fs/mod.rs
  • shell/src/functions/mod.rs
  • shell/src/functions/types.rs
  • shell/src/functions/workspace.rs
  • shell/src/main.rs
  • shell/tests/golden/schemas/coder.create-file.json
  • shell/tests/golden/schemas/coder.delete-file.json
  • shell/tests/golden/schemas/coder.list-folder.json
  • shell/tests/golden/schemas/coder.move.json
  • shell/tests/golden/schemas/coder.read-file.json
  • shell/tests/golden/schemas/coder.search.json
  • shell/tests/golden/schemas/coder.tree.json
  • shell/tests/golden/schemas/coder.update-file.json
💤 Files with no reviewable changes (8)
  • shell/tests/golden/schemas/coder.read-file.json
  • shell/tests/golden/schemas/coder.update-file.json
  • shell/tests/golden/schemas/coder.list-folder.json
  • shell/tests/golden/schemas/coder.tree.json
  • shell/tests/golden/schemas/coder.create-file.json
  • shell/tests/golden/schemas/coder.move.json
  • shell/tests/golden/schemas/coder.search.json
  • shell/tests/golden/schemas/coder.delete-file.json

Comment on lines +294 to 295
let resolver = resolver.session_scoped(req.base_dir.as_deref());
let cfg = cfg.read().await.clone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Don't treat wire base_dir as trusted input.

All of these handlers now widen the resolver with req.base_dir, but the corresponding request types still deserialize that field from raw JSON; #[schemars(skip)] only hides it from published schema. A direct coder::* call can therefore smuggle base_dir in the payload and escape the configured coder roots to any absolute directory that session_scoped() accepts. Strip or overwrite base_dir at the registration boundary, or split public request types from the internal harness-injected ones. Based on review stack context, harness-side stripping only protects the harness path; it does not harden direct function invocations.

Also applies to: 312-313, 330-331, 348-349, 365-366, 382-383, 400-401, 414-416

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shell/src/code/functions/mod.rs` around lines 294 - 295, The handlers that
call resolver.session_scoped(req.base_dir.as_deref()) are trusting req.base_dir
from deserialized request payloads, which lets direct coder::* calls widen
access outside configured roots. Fix this by preventing public request structs
from accepting base_dir at all, or by stripping/overwriting it at the
registration boundary before any handler like the resolver/session_scoped paths
use it. Keep the harness-injected base_dir separate from the public request
types so only internal harness code can supply it, and direct invocations always
use a safe, server-controlled value.

Comment thread shell/src/code/path.rs
Comment on lines 384 to 395
pub fn resolve_in(&self, base_dir: &str, path: &str) -> Result<PathBuf, CoderError> {
let base_path = Path::new(base_dir);
if !base_path.is_absolute() {
return Err(CoderError::BadInput(format!(
"base_dir must be an absolute path: {base_dir}"
)));
}
// (c) base_dir must canonicalise inside an EXISTING allowed root.
// Reuse the shared canonicalisation so a `..`/symlink escape in the
// session dir itself fails closed exactly like a wire path would.
let base_canon = self.canonicalize_wire(base_dir, Path::new(base_dir))?;
let base_canon = self.canonicalize_wire(base_dir, base_path)?;
if self.containing_root(&base_canon).is_none() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject file paths as base_dir here.

resolve_in() still accepts any canonicalized path under an allowed root, including regular files. That diverges from shell/src/fs/host.rs::confine_base_dir and shell/src/exec/policy.rs::confine_base_dir, both of which require an existing directory. With the current code, base_dir=/allowed/file.txt passes containment and relative joins then build impossible descendants under that file, so callers get a late I/O failure instead of a clean contract error.

Suggested fix
         let base_canon = self.canonicalize_wire(base_dir, base_path)?;
+        if !base_canon.is_dir() {
+            return Err(CoderError::BadInput(format!(
+                "base_dir is not a directory: {base_dir}"
+            )));
+        }
         if self.containing_root(&base_canon).is_none() {
             return Err(CoderError::OutsideBase(format!(
                 "base_dir is outside every allowed root: {base_dir}. \
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn resolve_in(&self, base_dir: &str, path: &str) -> Result<PathBuf, CoderError> {
let base_path = Path::new(base_dir);
if !base_path.is_absolute() {
return Err(CoderError::BadInput(format!(
"base_dir must be an absolute path: {base_dir}"
)));
}
// (c) base_dir must canonicalise inside an EXISTING allowed root.
// Reuse the shared canonicalisation so a `..`/symlink escape in the
// session dir itself fails closed exactly like a wire path would.
let base_canon = self.canonicalize_wire(base_dir, Path::new(base_dir))?;
let base_canon = self.canonicalize_wire(base_dir, base_path)?;
if self.containing_root(&base_canon).is_none() {
pub fn resolve_in(&self, base_dir: &str, path: &str) -> Result<PathBuf, CoderError> {
let base_path = Path::new(base_dir);
if !base_path.is_absolute() {
return Err(CoderError::BadInput(format!(
"base_dir must be an absolute path: {base_dir}"
)));
}
// (c) base_dir must canonicalise inside an EXISTING allowed root.
// Reuse the shared canonicalisation so a `..`/symlink escape in the
// session dir itself fails closed exactly like a wire path would.
let base_canon = self.canonicalize_wire(base_dir, base_path)?;
if !base_canon.is_dir() {
return Err(CoderError::BadInput(format!(
"base_dir is not a directory: {base_dir}"
)));
}
if self.containing_root(&base_canon).is_none() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shell/src/code/path.rs` around lines 384 - 395, resolve_in() currently allows
a canonicalized regular file to be used as base_dir, which should be rejected to
match confine_base_dir in shell/src/fs/host.rs and shell/src/exec/policy.rs.
Update the resolve_in() flow to verify the canonicalized base_path is an
existing directory before proceeding with containing_root and relative path
joins, and return a BadInput-style error when base_dir points to a file rather
than a directory.

Comment thread shell/src/fs/host.rs
Comment on lines +420 to +428
fn access_roots(host_roots_canon: &[PathBuf], base_dir_canon: Option<&Path>) -> Vec<PathBuf> {
let mut roots = host_roots_canon.to_vec();
if let Some(base) = base_dir_canon {
if !roots.iter().any(|r| r == base) {
roots.push(base.to_path_buf());
}
}
roots
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Preserve parent-root context for protected glob checks.

Adding base_dir as the only extra root strips the selected directory name before matching. If the harness selects a protected directory like /repo/.git as base_dir, a child such as config is matched as config, so patterns like **/.git/** no longer block it. Include the selected directory’s parent in the effective roots, or reject protected selected roots, so scoped roots outside host_roots cannot bypass non_accessible_globs.

🛡️ Proposed fix
 fn access_roots(host_roots_canon: &[PathBuf], base_dir_canon: Option<&Path>) -> Vec<PathBuf> {
     let mut roots = host_roots_canon.to_vec();
     if let Some(base) = base_dir_canon {
+        if let Some(parent) = base.parent() {
+            if !roots.iter().any(|r| r.as_path() == parent) {
+                roots.push(parent.to_path_buf());
+            }
+        }
         if !roots.iter().any(|r| r == base) {
             roots.push(base.to_path_buf());
         }
     }
     roots
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn access_roots(host_roots_canon: &[PathBuf], base_dir_canon: Option<&Path>) -> Vec<PathBuf> {
let mut roots = host_roots_canon.to_vec();
if let Some(base) = base_dir_canon {
if !roots.iter().any(|r| r == base) {
roots.push(base.to_path_buf());
}
}
roots
}
fn access_roots(host_roots_canon: &[PathBuf], base_dir_canon: Option<&Path>) -> Vec<PathBuf> {
let mut roots = host_roots_canon.to_vec();
if let Some(base) = base_dir_canon {
if let Some(parent) = base.parent() {
if !roots.iter().any(|r| r.as_path() == parent) {
roots.push(parent.to_path_buf());
}
}
if !roots.iter().any(|r| r == base) {
roots.push(base.to_path_buf());
}
}
roots
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shell/src/fs/host.rs` around lines 420 - 428, The access_roots helper is
dropping parent-directory context when base_dir_canon is added alone, which can
let protected scoped roots bypass non_accessible_globs checks. Update
access_roots so the selected base directory’s parent is also included in the
effective roots (or reject protected selected roots outright), and verify the
protected-path matching path in shell/src/fs/host.rs still preserves names like
.git for access checks.

@ytallo
ytallo merged commit a4a06ed into main Jun 30, 2026
38 checks passed
andersonleal added a commit that referenced this pull request Jun 30, 2026
* feat(workflow): durable DAG workflow worker

A new `workflow` worker that orchestrates multi-agent pipelines as a durable
directed-acyclic graph on the iii engine.

- DAG core: typed node/run state, dependency validation, multi-input joins,
  topological scheduling, and crash-safe durable persistence with reconcile.
- Execution: per-node (optionally nested) agent sessions, fast-wake on
  turn-completed, caller notify callbacks, and a sweep loop with timeouts.
- Permissions: inherit-by-default node permissions, per-node router deny.
- Fire-and-forget `workflow::start`: callers get the run_id back immediately
  and receive the outcome via `reply_to` / `notify` or by polling
  `workflow::status`; no blocking `await` path.
- Workspace picker control plane for the shell (#376).
- Add `llm-router` and `workflow` dependencies to iii.worker.yaml and
  workflow.yaml; remove outdated observability and workflow plan docs.

Claude-Session: https://claude.ai/code/session_019pMG6VY8cKLW2EzF1FEkV9

* fix(workflow): address PR review findings

- start: fold each node's input.from/fanout.over reads into depends_on before
  validate+persist, so a node never schedules before a node it reads (was: the
  read resolved to null while the run still reported success); reserve '/' in
  node ids too (it's the run_id/node_uid storage-key separator).
- state(delete_run): propagate child deletes so the run record survives as the
  retry anchor on a transient failure, and clear the session reverse-index so GC
  stops leaking index rows.
- locks: hold Weak (not Arc) per-run mutexes so a finished run's lock frees
  instead of pinning forever.
- configuration: release the harness-hook bind claim on a partial bind so a
  later registry-change event retries; commit a new sweep_expression only after
  the cron rebind actually succeeds.
- tick(build_opening): escape angle brackets in the untrusted fenced input so
  upstream output can't forge a </workflow_input> break-out.
- events(reply): drop the dead top-level `run` field — harness::send has no such
  field (verified against harness send.rs) and always drives a turn; the captured
  caller policy already rides in options.functions.
- node-result: serialize an explicit `{ "result": null }` instead of dropping
  the key, matching the documented contract.
- main: warn and fall back to WorkerConfig::default() on a config-fetch error
  instead of aborting boot (matches the other worker binaries).
- inject_guidance: correct the default-node-policy wording (reach minus the
  workflow control plane), and README: sdk 0.19.2 -> 0.20.0 (pinned iii-sdk).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants