Skip to content

Commit 23d6264

Browse files
authored
feat(shell): redis console store with cross-language streaming integ (#795)
* feat(shell): redis console store with cross-language streaming integ * feat(shell): console config block, shared console battery, redis store package * refactor(shell): job_table package, and codex fixes on the redis console Split job_table into types/constants/table in both languages, mirroring the console package. Redis console review fixes: the minted key prefix is public (JobConsole .store plus RedisConsoleStore.key_prefix) so an external reader can be handed a console's address; keys expire ttl_seconds after the last append (default one day) instead of accumulating; the ending chunk is terminal in the store itself, so an emit racing a kill past the local guard is dropped server-side; and the TS loader validates the console block's value types the way Pydantic already did.
1 parent ebec2d6 commit 23d6264

59 files changed

Lines changed: 1827 additions & 160 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

integ/console/jobs.json

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"cases": [
3+
{
4+
"id": "console_bg_wait_adopts_output",
5+
"seq": 509000,
6+
"targets": [
7+
"ram",
8+
"console-redis"
9+
],
10+
"command": "(echo out; echo err 1>&2) & wait",
11+
"expect": {
12+
"exit": 0,
13+
"stdout": "out\n",
14+
"stderr": "err\n"
15+
}
16+
},
17+
{
18+
"id": "console_bg_wait_exit_code",
19+
"seq": 509002,
20+
"targets": [
21+
"ram",
22+
"console-redis"
23+
],
24+
"command": "(sh -c 'exit 4' & wait $!; echo rc=$?) 2>/dev/null",
25+
"expect": {
26+
"exit": 0,
27+
"stdout": "rc=4\n",
28+
"stderr": ""
29+
}
30+
},
31+
{
32+
"id": "console_kill_by_bg_id",
33+
"seq": 509004,
34+
"targets": [
35+
"ram",
36+
"console-redis"
37+
],
38+
"command": "(sleep 5 & kill $! && echo killed) 2>/dev/null",
39+
"expect": {
40+
"exit": 0,
41+
"stdout": "killed\n",
42+
"stderr": ""
43+
}
44+
},
45+
{
46+
"id": "console_bg_write_lands",
47+
"seq": 509006,
48+
"targets": [
49+
"ram",
50+
"console-redis"
51+
],
52+
"command": "(echo payload > /data/console-bg.txt & wait) 2>/dev/null; cat /data/console-bg.txt; rm /data/console-bg.txt",
53+
"expect": {
54+
"exit": 0,
55+
"stdout": "payload\n",
56+
"stderr": ""
57+
}
58+
}
59+
]
60+
}

integ/fixtures/config/accepted.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@
9090
"mounts": { "/d": { "resource": "ram" } },
9191
"clis": { "tool": { "script": "tool.py", "runtime": "py", "config": { "token": "t" } } }
9292
}
93+
},
94+
{
95+
"name": "console block: ram form",
96+
"config": {
97+
"mounts": { "/d": { "resource": "ram" } },
98+
"console": { "type": "ram" }
99+
}
100+
},
101+
{
102+
"name": "console block: redis form, every key",
103+
"config": {
104+
"mounts": { "/d": { "resource": "ram" } },
105+
"console": { "type": "redis", "url": "redis://localhost:6379/5", "key_prefix": "c:", "ttl_seconds": 3600 }
106+
}
107+
},
108+
{
109+
"name": "console block: null ttl keeps keys forever",
110+
"config": {
111+
"mounts": { "/d": { "resource": "ram" } },
112+
"console": { "type": "redis", "ttl_seconds": null }
113+
}
93114
}
94115
]
95116
}

integ/fixtures/config/rejected.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,34 @@
101101
"mounts": { "/d": { "resource": "ram" } },
102102
"store": { "type": "ram", "observer": { "type": "s3", "bucket": "b" } }
103103
}
104+
},
105+
{
106+
"name": "console block: unknown key",
107+
"config": {
108+
"mounts": { "/d": { "resource": "ram" } },
109+
"console": { "type": "redis", "prefix": "c:" }
110+
}
111+
},
112+
{
113+
"name": "console block: unknown type",
114+
"config": {
115+
"mounts": { "/d": { "resource": "ram" } },
116+
"console": { "type": "disk" }
117+
}
118+
},
119+
{
120+
"name": "console block: url not a string",
121+
"config": {
122+
"mounts": { "/d": { "resource": "ram" } },
123+
"console": { "type": "redis", "url": 123 }
124+
}
125+
},
126+
{
127+
"name": "console block: zero ttl",
128+
"config": {
129+
"mounts": { "/d": { "resource": "ram" } },
130+
"console": { "type": "redis", "ttl_seconds": 0 }
131+
}
104132
}
105133
]
106134
}

integ/runners/python/adapters.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@
110110
from mirage.resource.tencent import TencentConfig, TencentResource
111111
from mirage.resource.trello import TrelloConfig, TrelloResource
112112
from mirage.resource.wasabi import WasabiConfig, WasabiResource
113+
from mirage.shell.console import JobConsole
114+
from mirage.shell.console.redis import RedisConsoleStore
115+
from mirage.shell.job_table import ConsoleFactory
113116
from mirage.types import ConsistencyPolicy
114117

115118
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
@@ -2273,6 +2276,42 @@ async def teardown_target(
22732276
await service.teardown()
22742277

22752278

2279+
def _redis_console(url: str, prefix: str, job_id: int) -> JobConsole:
2280+
"""One job's console on its own Redis stream.
2281+
2282+
The nonce beside the id matters because battery cases reap jobs and
2283+
ids restart at 1; a reused stream would replay the previous case's
2284+
chunks, ending chunk included.
2285+
2286+
Args:
2287+
url (str): Redis connection URL.
2288+
prefix (str): this run's key namespace.
2289+
job_id (int): the job the console is being built for.
2290+
"""
2291+
key_prefix = f"{prefix}{uuid.uuid4().hex[:8]}-{job_id}:"
2292+
# Battery keys must not accumulate in the shared redis db.
2293+
return JobConsole(store=RedisConsoleStore(
2294+
url=url, key_prefix=key_prefix, ttl_seconds=3600))
2295+
2296+
2297+
def console_factory(target: dict, run_id: str) -> ConsoleFactory | None:
2298+
"""Build the target's console factory, or None for in-memory.
2299+
2300+
A target opts in with ``"console": {"type": "redis"}``; the stream
2301+
keys ride REDIS_URL under a per-run namespace.
2302+
2303+
Args:
2304+
target (dict): the target manifest entry.
2305+
run_id (str): this open's unique id.
2306+
"""
2307+
block = target.get("console")
2308+
if block is None:
2309+
return None
2310+
url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
2311+
return functools.partial(_redis_console, url,
2312+
f"mirage-integ-console-{run_id}:")
2313+
2314+
22762315
async def open_target(
22772316
target: dict,
22782317
consistency: ConsistencyPolicy | None = None
@@ -2281,13 +2320,18 @@ async def open_target(
22812320
service = await make_service(target, run_id)
22822321
mounts, cleanups = await build_mounts(target, run_id, service)
22832322
agent_id = target.get("agentId")
2323+
factory = console_factory(target, run_id)
22842324
if consistency is not None:
22852325
ws = Workspace(mounts,
22862326
mode=MountMode.WRITE,
22872327
consistency=consistency,
2288-
agent_id=agent_id)
2328+
agent_id=agent_id,
2329+
console_factory=factory)
22892330
else:
2290-
ws = Workspace(mounts, mode=MountMode.WRITE, agent_id=agent_id)
2331+
ws = Workspace(mounts,
2332+
mode=MountMode.WRITE,
2333+
agent_id=agent_id,
2334+
console_factory=factory)
22912335
for cli_name in target.get("clis", []):
22922336
spec, config = cli_install(service, cli_name)
22932337
ws.register_cli(cli_name, spec, config)

integ/runners/python/harness.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424

2525
# integ/runtime holds the runtime suite (its own schema and runners,
2626
# integ/runtime/run.{py,ts} + cli.sh), not battery cases; keep it out.
27-
CASE_DIRS = ("unix", "bash", "crossmount", "resources", "cli", "session")
27+
CASE_DIRS = ("unix", "bash", "crossmount", "resources", "cli", "session",
28+
"console")
2829

2930

3031
def integ_root() -> Path:

integ/runners/typescript/adapters.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// limitations under the License.
1313
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
1414

15+
import { randomBytes } from 'node:crypto'
1516
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
1617
import { tmpdir } from 'node:os'
1718
import { join, relative, sep } from 'node:path'
@@ -57,6 +58,7 @@ import {
5758
SLACK,
5859
HfBucketsResource,
5960
JaegerResource,
61+
JobConsole,
6062
LanceDBResource,
6163
LangfuseResource,
6264
LinearResource,
@@ -74,6 +76,7 @@ import {
7476
QingStorResource,
7577
R2Resource,
7678
RAMResource,
79+
RedisConsoleStore,
7780
RedisResource,
7881
type Resource,
7982
S3Resource,
@@ -87,6 +90,7 @@ import {
8790
TrelloResource,
8891
WasabiResource,
8992
Workspace,
93+
type ConsoleFactory,
9094
} from '@struktoai/mirage-node'
9195
import * as lancedb from '@lancedb/lancedb'
9296
import { QdrantClient } from '@qdrant/js-client-rest'
@@ -179,6 +183,28 @@ function installLocalClis(ws: { registerCli: (name: string, spec: unknown) => vo
179183
if (target.clis?.includes('git') === true) ws.registerCli('git', GIT)
180184
}
181185

186+
// Where a target declares console: {type: 'redis'}, each job's console
187+
// rides its own Redis stream on REDIS_URL. The nonce beside the id
188+
// matters because battery cases reap jobs and ids restart at 1; a
189+
// reused stream would replay the previous case's chunks, ending chunk
190+
// included. Only the ram opener consults this (main.ts refuses a
191+
// console block on any other resource), and ws.close() releases the
192+
// clients through JobTable.closeConsoles.
193+
function consoleFactoryFor(target: Target): ConsoleFactory | undefined {
194+
if (target.console?.type !== 'redis') return undefined
195+
const url = process.env.REDIS_URL ?? 'redis://localhost:6379/0'
196+
const prefix = `mirage-integ-console-${randomBytes(4).toString('hex')}:`
197+
return (jobId: number) =>
198+
new JobConsole(
199+
new RedisConsoleStore({
200+
url,
201+
keyPrefix: `${prefix}${randomBytes(4).toString('hex')}-${jobId.toString()}:`,
202+
// Battery keys must not accumulate in the shared redis db.
203+
ttlSeconds: 3600,
204+
}),
205+
)
206+
}
207+
182208
async function openRam(target: Target): Promise<Open> {
183209
const mounts: Record<string, RAMResource | [RAMResource, MountMode]> = {}
184210
const built: Record<string, RAMResource> = {}
@@ -194,9 +220,11 @@ async function openRam(target: Target): Promise<Open> {
194220
built[m.path] = resource
195221
mounts[m.path] = m.mode === 'read' ? [resource, MountMode.READ] : resource
196222
}
223+
const consoleFactory = consoleFactoryFor(target)
197224
const ws = new Workspace(mounts, {
198225
mode: MountMode.WRITE,
199226
...(target.agentId !== undefined ? { agentId: target.agentId } : {}),
227+
...(consoleFactory !== undefined ? { consoleFactory } : {}),
200228
})
201229
installLocalClis(ws, target)
202230
return { ws: ws as unknown as ExecWorkspace, cleanup: () => ws.close() }

integ/runners/typescript/harness.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url'
2020

2121
// integ/runtime holds the runtime suite (its own schema and runners,
2222
// integ/runtime/run.{py,ts} + cli.sh), not battery cases; keep it out.
23-
const CASE_DIRS = ['unix', 'bash', 'crossmount', 'resources', 'cli', 'session']
23+
const CASE_DIRS = ['unix', 'bash', 'crossmount', 'resources', 'cli', 'session', 'console']
2424
const ENC = new TextEncoder()
2525
const DEC = new TextDecoder()
2626

@@ -65,6 +65,10 @@ export interface Target {
6565
dataset?: string
6666
agentId?: string
6767
facet?: string
68+
// Where background-job consoles live: { type: 'redis' } puts each
69+
// job's console on its own Redis stream (REDIS_URL). Only the ram
70+
// opener consults it; main.ts refuses it on any other resource.
71+
console?: { type?: string }
6872
clis?: string[]
6973
// Scope an installed account CLI to this mount's folder, so the CLI and
7074
// the mount are pointed at the same place.

integ/runners/typescript/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ async function runTarget(
7373
report: Report | null,
7474
emit: EmitRow[] | null,
7575
): Promise<void> {
76+
// A console block is only wired into the ram opener; refusing it
77+
// anywhere else keeps a silently RAM-consoled "redis console" target
78+
// from reading as covered.
79+
if (target.console !== undefined && target.mounts[0].resource !== 'ram') {
80+
throw new Error(`target ${target.id}: console targets ride ram mounts`)
81+
}
7682
const { ws, cleanup } = await ADAPTERS[target.mounts[0].resource](target)
7783
try {
7884
// A target's declared environment. A CLI whose spec reads a variable

integ/targets.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,29 @@
320320
}
321321
}
322322
},
323+
{
324+
"id": "console-redis",
325+
"hosts": [
326+
"python",
327+
"typescript-node"
328+
],
329+
"service": "redis",
330+
"console": {
331+
"type": "redis"
332+
},
333+
"mounts": [
334+
{
335+
"path": "/data",
336+
"resource": "ram",
337+
"backend": "memory"
338+
},
339+
{
340+
"path": "/data2",
341+
"resource": "ram",
342+
"backend": "memory"
343+
}
344+
]
345+
},
323346
{
324347
"id": "opfs",
325348
"hosts": [

0 commit comments

Comments
 (0)