bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
359 lines
14 KiB
Python
359 lines
14 KiB
Python
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
|
|
import fnmatch
|
|
from collections.abc import Callable
|
|
|
|
import tree_sitter
|
|
|
|
from mirage.io import IOResult
|
|
from mirage.io.async_line_iterator import AsyncLineIterator
|
|
from mirage.io.stream import async_chain
|
|
from mirage.io.types import ByteSource
|
|
from mirage.shell.barrier import BarrierPolicy, apply_barrier
|
|
from mirage.shell.call_stack import CallStack
|
|
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
|
from mirage.types import PathSpec, word_text
|
|
from mirage.workspace.session import Session
|
|
from mirage.workspace.types import ExecutionNode
|
|
|
|
# Safety cap on while/until iterations. Independent of stdin size:
|
|
# even with lazy stdin (Step 15), a `while read` over a stream longer
|
|
# than this cap stops here. Cap-hit emits a stderr warning so callers
|
|
# notice silent truncation. Bump if agents process larger streams.
|
|
_MAX_WHILE = 10000
|
|
|
|
|
|
def _line_buffer(stdin: ByteSource) -> AsyncLineIterator:
|
|
"""Wrap a ByteSource (bytes or chunked async iter) as a line iterator."""
|
|
if isinstance(stdin, bytes):
|
|
return AsyncLineIterator(async_chain(stdin))
|
|
return AsyncLineIterator(stdin)
|
|
|
|
|
|
async def _execute_body(
|
|
execute_node: Callable,
|
|
body: list[tree_sitter.Node],
|
|
session: Session,
|
|
stdin: ByteSource | None,
|
|
call_stack: CallStack | None,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
"""Execute a list of body commands sequentially."""
|
|
all_stdout: list[ByteSource | None] = []
|
|
merged_io = IOResult()
|
|
last_exec = ExecutionNode(command="", exit_code=0)
|
|
for cmd in body:
|
|
try:
|
|
stdout, io, last_exec = await execute_node(cmd, session, stdin,
|
|
call_stack)
|
|
except BreakSignal as sig:
|
|
if sig.stdout is not None:
|
|
all_stdout.append(sig.stdout)
|
|
merged_io = await merged_io.merge(sig.io)
|
|
combined = async_chain(*[s for s in all_stdout
|
|
if s is not None]) if any(
|
|
s is not None
|
|
for s in all_stdout) else None
|
|
raise BreakSignal(stdout=combined, io=merged_io)
|
|
except ContinueSignal as sig:
|
|
if sig.stdout is not None:
|
|
all_stdout.append(sig.stdout)
|
|
merged_io = await merged_io.merge(sig.io)
|
|
combined = async_chain(*[s for s in all_stdout
|
|
if s is not None]) if any(
|
|
s is not None
|
|
for s in all_stdout) else None
|
|
raise ContinueSignal(stdout=combined, io=merged_io)
|
|
all_stdout.append(stdout)
|
|
merged_io = await merged_io.merge(io)
|
|
if (io.exit_code != 0 and session.shell_options.get("errexit")
|
|
and cmd.type not in ERREXIT_EXEMPT_TYPES):
|
|
merged_io.exit_code = io.exit_code
|
|
break
|
|
non_empty = [s for s in all_stdout if s is not None]
|
|
combined = async_chain(*non_empty) if non_empty else None
|
|
return combined, merged_io, last_exec
|
|
|
|
|
|
class BreakSignal(Exception):
|
|
|
|
def __init__(self, stdout=None, io=None):
|
|
self.stdout = stdout
|
|
self.io = io or IOResult()
|
|
|
|
|
|
class ContinueSignal(Exception):
|
|
|
|
def __init__(self, stdout=None, io=None):
|
|
self.stdout = stdout
|
|
self.io = io or IOResult()
|
|
|
|
|
|
class ReturnSignal(Exception):
|
|
|
|
def __init__(self, exit_code: int = 0, stderr: bytes = b"") -> None:
|
|
self.exit_code = exit_code
|
|
self.stderr = stderr
|
|
|
|
|
|
def _collect_loop_result(
|
|
all_stdout: list[ByteSource | None],
|
|
merged_io: IOResult,
|
|
label: str,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
exec_node = ExecutionNode(command=label, exit_code=merged_io.exit_code)
|
|
non_empty = [s for s in all_stdout if s is not None]
|
|
if not non_empty:
|
|
return None, merged_io, exec_node
|
|
return async_chain(*non_empty), merged_io, exec_node
|
|
|
|
|
|
async def handle_if(
|
|
execute_node: Callable,
|
|
branches: list[tuple[tree_sitter.Node, list[tree_sitter.Node]]],
|
|
else_body: list[tree_sitter.Node] | None,
|
|
session: Session,
|
|
stdin: ByteSource | None = None,
|
|
call_stack: CallStack | None = None,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
for condition, body in branches:
|
|
cond_stdout, cond_io, _ = await execute_node(condition, session, stdin,
|
|
call_stack)
|
|
await apply_barrier(cond_stdout, cond_io, BarrierPolicy.STATUS)
|
|
session.last_exit_code = cond_io.exit_code
|
|
if cond_io.exit_code == 0:
|
|
return await _execute_body(execute_node, body, session, stdin,
|
|
call_stack)
|
|
if else_body is not None:
|
|
return await _execute_body(execute_node, else_body, session, stdin,
|
|
call_stack)
|
|
return None, IOResult(), ExecutionNode(exit_code=0)
|
|
|
|
|
|
async def handle_for(
|
|
execute_node: Callable,
|
|
variable: str,
|
|
values: list[str | PathSpec],
|
|
body: list[tree_sitter.Node],
|
|
session: Session,
|
|
stdin: ByteSource | None = None,
|
|
call_stack: CallStack | None = None,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
merged_io = IOResult()
|
|
all_stdout: list[ByteSource | None] = []
|
|
saved = session.env.get(variable)
|
|
|
|
# Save and materialize stdin for re-reading across iterations
|
|
prev_buffer = session._stdin_buffer
|
|
if stdin is not None:
|
|
session._stdin_buffer = _line_buffer(stdin)
|
|
stdin = None
|
|
|
|
try:
|
|
for val in values:
|
|
# env stores strings only; bash keeps `for f in sub/*.txt`
|
|
# matches relative, so the loop variable takes the typed form
|
|
session.env[variable] = word_text(val)
|
|
try:
|
|
stdout, io, _ = await _execute_body(execute_node, body,
|
|
session, stdin, call_stack)
|
|
except BreakSignal as sig:
|
|
if sig.stdout is not None:
|
|
all_stdout.append(sig.stdout)
|
|
merged_io = await merged_io.merge(sig.io)
|
|
break
|
|
except ContinueSignal as sig:
|
|
if sig.stdout is not None:
|
|
all_stdout.append(sig.stdout)
|
|
merged_io = await merged_io.merge(sig.io)
|
|
continue
|
|
merged_io = await merged_io.merge(io)
|
|
all_stdout.append(stdout)
|
|
finally:
|
|
session._stdin_buffer = prev_buffer
|
|
if saved is not None:
|
|
session.env[variable] = saved
|
|
else:
|
|
session.env.pop(variable, None)
|
|
return _collect_loop_result(all_stdout, merged_io, "for")
|
|
|
|
|
|
async def _condition_loop(
|
|
execute_node: Callable,
|
|
condition: tree_sitter.Node,
|
|
body: list[tree_sitter.Node],
|
|
session: Session,
|
|
stdin: ByteSource | None,
|
|
call_stack: CallStack | None,
|
|
label: str,
|
|
break_on_zero: bool,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
merged_io = IOResult()
|
|
all_stdout: list[ByteSource | None] = []
|
|
prev_buffer = session._stdin_buffer
|
|
if stdin is not None:
|
|
session._stdin_buffer = _line_buffer(stdin)
|
|
stdin = None
|
|
try:
|
|
hit_limit = True
|
|
for _ in range(_MAX_WHILE):
|
|
cond_stdout, cond_io, _ = await execute_node(
|
|
condition, session, stdin, call_stack)
|
|
await apply_barrier(cond_stdout, cond_io, BarrierPolicy.STATUS)
|
|
session.last_exit_code = cond_io.exit_code
|
|
if break_on_zero and cond_io.exit_code == 0:
|
|
hit_limit = False
|
|
break
|
|
if (not break_on_zero and cond_io.exit_code != 0):
|
|
hit_limit = False
|
|
break
|
|
try:
|
|
stdout, io, _ = await _execute_body(execute_node, body,
|
|
session, stdin, call_stack)
|
|
except BreakSignal as sig:
|
|
hit_limit = False
|
|
if sig.stdout is not None:
|
|
all_stdout.append(sig.stdout)
|
|
merged_io = await merged_io.merge(sig.io)
|
|
break
|
|
except ContinueSignal as sig:
|
|
if sig.stdout is not None:
|
|
all_stdout.append(sig.stdout)
|
|
merged_io = await merged_io.merge(sig.io)
|
|
continue
|
|
merged_io = await merged_io.merge(io)
|
|
all_stdout.append(stdout)
|
|
if hit_limit:
|
|
warn = (f"warning: {label} loop terminated after "
|
|
f"{_MAX_WHILE} iterations\n").encode()
|
|
existing = merged_io.stderr
|
|
if isinstance(existing, bytes) and existing:
|
|
merged_io.stderr = existing + warn
|
|
else:
|
|
merged_io.stderr = warn
|
|
finally:
|
|
session._stdin_buffer = prev_buffer
|
|
return _collect_loop_result(all_stdout, merged_io, label)
|
|
|
|
|
|
async def handle_while(
|
|
execute_node: Callable,
|
|
condition: tree_sitter.Node,
|
|
body: list[tree_sitter.Node],
|
|
session: Session,
|
|
stdin: ByteSource | None = None,
|
|
call_stack: CallStack | None = None,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
return await _condition_loop(execute_node,
|
|
condition,
|
|
body,
|
|
session,
|
|
stdin,
|
|
call_stack,
|
|
"while",
|
|
break_on_zero=False)
|
|
|
|
|
|
async def handle_until(
|
|
execute_node: Callable,
|
|
condition: tree_sitter.Node,
|
|
body: list[tree_sitter.Node],
|
|
session: Session,
|
|
stdin: ByteSource | None = None,
|
|
call_stack: CallStack | None = None,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
return await _condition_loop(execute_node,
|
|
condition,
|
|
body,
|
|
session,
|
|
stdin,
|
|
call_stack,
|
|
"until",
|
|
break_on_zero=True)
|
|
|
|
|
|
async def handle_case(
|
|
execute_node: Callable,
|
|
word: str,
|
|
items: list[tuple[list[str], list[tree_sitter.Node]]],
|
|
session: Session,
|
|
stdin: ByteSource | None = None,
|
|
call_stack: CallStack | None = None,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
for patterns, body in items:
|
|
if any(fnmatch.fnmatch(word, p.strip()) for p in patterns):
|
|
all_stdout: list[ByteSource] = []
|
|
merged_io = IOResult()
|
|
last_exec = ExecutionNode(command="case", exit_code=0)
|
|
for stmt in body:
|
|
stdout, io, last_exec = await execute_node(
|
|
stmt, session, stdin, call_stack)
|
|
stdin = None
|
|
if stdout is not None:
|
|
all_stdout.append(stdout)
|
|
merged_io = await merged_io.merge(io)
|
|
if len(all_stdout) == 1:
|
|
return all_stdout[0], merged_io, last_exec
|
|
combined = async_chain(*all_stdout) if all_stdout else None
|
|
return combined, merged_io, last_exec
|
|
return None, IOResult(), ExecutionNode(command="case", exit_code=0)
|
|
|
|
|
|
async def handle_select(
|
|
execute_node: Callable,
|
|
variable: str,
|
|
values: list[str | PathSpec],
|
|
body: list[tree_sitter.Node],
|
|
session: Session,
|
|
stdin: ByteSource | None = None,
|
|
call_stack: CallStack | None = None,
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
merged_io = IOResult()
|
|
all_stdout: list[ByteSource | None] = []
|
|
saved = session.env.get(variable)
|
|
|
|
# Save and materialize stdin for re-reading across iterations
|
|
prev_buffer = session._stdin_buffer
|
|
if stdin is not None:
|
|
session._stdin_buffer = _line_buffer(stdin)
|
|
stdin = None
|
|
|
|
try:
|
|
for val in values:
|
|
# env stores strings only; bash keeps `for f in sub/*.txt`
|
|
# matches relative, so the loop variable takes the typed form
|
|
session.env[variable] = word_text(val)
|
|
try:
|
|
stdout, io, _ = await _execute_body(execute_node, body,
|
|
session, stdin, call_stack)
|
|
except BreakSignal as sig:
|
|
if sig.stdout is not None:
|
|
all_stdout.append(sig.stdout)
|
|
merged_io = await merged_io.merge(sig.io)
|
|
break
|
|
except ContinueSignal as sig:
|
|
if sig.stdout is not None:
|
|
all_stdout.append(sig.stdout)
|
|
merged_io = await merged_io.merge(sig.io)
|
|
continue
|
|
merged_io = await merged_io.merge(io)
|
|
all_stdout.append(stdout)
|
|
finally:
|
|
session._stdin_buffer = prev_buffer
|
|
if saved is not None:
|
|
session.env[variable] = saved
|
|
else:
|
|
session.env.pop(variable, None)
|
|
return _collect_loop_result(all_stdout, merged_io, "select")
|