Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
988c481
Re-vendor Interchange at 4ed8baf4: the workflow.json retirement
TheGreatAxios Aug 20, 2026
f54bc03
Folded launch: build the single-step agent from resolved fields
TheGreatAxios Aug 20, 2026
584ec2e
Update docs: map the workflow.json retirement's conversion sites
TheGreatAxios Aug 20, 2026
5184125
Add tests for onTrigger onBodyFailure and its projection
TheGreatAxios Aug 20, 2026
3609ddc
onTrigger: restore the onBodyFailure policy and carry it through proj…
TheGreatAxios Aug 20, 2026
603e702
Add tests for the adopting code-sourced deploy front
TheGreatAxios Aug 20, 2026
aed9ba9
hub-sessions: a code-sourced deploy front that adopts an existing anc…
TheGreatAxios Aug 20, 2026
762916e
Update docs: ledger the onBodyFailure projection and the adopting dep…
TheGreatAxios Aug 20, 2026
033e808
Add tests for the agent-runtime workflow source package
TheGreatAxios Aug 20, 2026
4baaad8
Agent runtime: one versioned workflow source package, configured per …
TheGreatAxios Aug 20, 2026
9ac8650
Agent runtime: render the per-run config into the deployed bytes
TheGreatAxios Aug 20, 2026
5e625af
Update docs: the agent-runtime package and what still blocks deployAt…
TheGreatAxios Aug 20, 2026
3daf55f
Add tests for the code-sourced folded-run deploy
TheGreatAxios Aug 20, 2026
9d88405
folded-runs: deploy a rendered source package, not a synthesized defi…
TheGreatAxios Aug 20, 2026
7390e24
Update docs: deployAtHead is on the code-sourced seam
TheGreatAxios Aug 20, 2026
f39bcec
Add tests for the sidecar's closure-sourced deploy
Aug 20, 2026
d7ca556
Sidecar: deploy from the closure, not from workflow.json
Aug 20, 2026
69dcd44
Update docs: the sidecar is on closures, and what is still unproven
Aug 20, 2026
e4dec77
Rebase onto main: reconcile the chat suite with the room-store cutover
TheGreatAxios Aug 20, 2026
02a267f
mcp-tools: bump to 0.0.5 for the mcp.<slug> credential-handle change
TheGreatAxios Aug 20, 2026
6a9e36f
seed: push a workflow source codebase, not the retired workflow.json …
TheGreatAxios Aug 20, 2026
4a7c79c
seed: deploy the pushed workflow source by source-ref, not by assetId
TheGreatAxios Aug 20, 2026
2b1dc80
Update the seed's test doubles for the source-ref deploy body
TheGreatAxios Aug 20, 2026
114ce4e
Update docs: what the first real boot on the new rails found
TheGreatAxios Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 70 additions & 46 deletions VENDORED.md

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,6 @@ export async function createHub(config: HubConfig) {
sidecarRouter,
eventCollectors,
credentialCipher,
hubPublicKey,
toolGrantsForPins,
mcpCredentialBindingsFor,
noopInferenceBaseUrl: `${config.baseUrl}/api/chat/noop-inference`,
Expand Down Expand Up @@ -1650,7 +1649,6 @@ export async function createHub(config: HubConfig) {
assetService,
sidecarRouter,
eventCollectors,
hubPublicKey,
toolGrantsForPins,
mcpCredentialBindingsFor,
cryptoProviderCache: foldedRunCryptoProviders,
Expand Down Expand Up @@ -2198,7 +2196,6 @@ export async function createHub(config: HubConfig) {
sidecarRouter,
eventCollectors,
credentialCipher,
hubPublicKey,
toolGrantsForPins,
mcpCredentialBindingsFor,
cryptoProviderCache: foldedRunCryptoProviders,
Expand Down
7 changes: 0 additions & 7 deletions apps/hub/src/routine-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ function buildLauncher(overrides: { definition?: unknown } = {}) {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand Down Expand Up @@ -232,7 +231,6 @@ describe("createHubRoutineLauncher — delivery workbench", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand All @@ -259,7 +257,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand Down Expand Up @@ -297,7 +294,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand All @@ -324,7 +320,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand Down Expand Up @@ -356,7 +351,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand All @@ -378,7 +372,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => {
sessionService: {} as never,
assetService: {} as never,
sidecarRouter: {} as never,
hubPublicKey: "hub-key",
toolGrantsForPins: () => [],
eventCollectors: {} as never,
cryptoProviderCache: { get: async () => ({}) as never },
Expand Down
21 changes: 21 additions & 0 deletions apps/sidecar/bin/workflow-probe-child
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bun
// One-shot workflow-probe child. The sidecar host spawns this by path
// (`Bun.spawn([binaryPath])`).
//
// The child reads the materialized package dir and IPC anchors from its
// fresh env, evaluates the workflow entry behind the airlock, and ships
// one HMAC-signed result frame on stdout. An evaluation failure is shipped
// as an `ok: false` frame (handled inside the runner), so a throw reaching
// here is a pre-evaluation defect (bad env, unwritable stdout) that exits
// non-zero -- the host then reaps and answers `workflow.probe.error`.
import { runWorkflowProbeChildFromProcessEnv } from "../src/workflow-probe-handler";

try {
await runWorkflowProbeChildFromProcessEnv();
process.exit(0);
} catch (err) {
process.stderr.write(
`workflow probe child failed: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
}
30 changes: 29 additions & 1 deletion apps/sidecar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ import { createTarballCache } from "@intx/tool-packaging";
import { hexEncode } from "@intx/types";

import { readSidecarConfig } from "./config";
import { DEFAULT_TOOL_REGISTRIES_JSON } from "./tool-materialization";
import {
DEFAULT_TOOL_REGISTRIES_JSON,
parseToolRegistries,
} from "./tool-materialization";
import { createWorkflowProbeExecutor } from "./workflow-probe-handler";
import { createWorkflowClosureMaterializer } from "./workflow-closure-materialization";
import { MAX_INLINE_ASSET_PAYLOAD_BYTES } from "./source-asset-delivery";
import { createDefaultHarnessBuilder } from "./default-harness";
import { createHubLinkWatchdog } from "./hub-link-watchdog";
import { drainWithTimeout } from "./shutdown";
Expand Down Expand Up @@ -181,6 +187,27 @@ if (config.tmpdir !== undefined) {
// `canBuildSource` predicate against the one adapter registry.
const buildHarness = createDefaultHarnessBuilder({ adapters });

// Airlocked workflow-probe executor, assembled here and injected through the
// orchestrator so the sidecar answers `workflow.probe.request` with a real
// inert projection and its wire hash instead of the hub-link's rejecting
// placeholder. The materializer lays a probe frame's frozen closure out under
// a per-probe scratch dir (rooted in the sidecar data dir so it shares that
// dir's lifecycle); the executor spawns the one-shot child that evaluates the
// workflow entry against it. A probe delivers its source assets inline in one
// frame, capped by the shared inline-payload bound.
const workflowProbeExecutor = createWorkflowProbeExecutor({
materialize: createWorkflowClosureMaterializer({
cacheRoot: CACHE_ROOT,
cacheMaxBytes: CACHE_MAX_BYTES,
registryMaxTarballBytes: REGISTRY_MAX_TARBALL_BYTES,
maxAssetPayloadBytes: MAX_INLINE_ASSET_PAYLOAD_BYTES,
registries: parseToolRegistries(
config.toolRegistries ?? DEFAULT_TOOL_REGISTRIES_JSON,
),
scratchRoot: path.join(config.dataDir, "workflow-probe", "closures"),
}),
});

const watchdogLog = getLogger(["sidecar", "hub-link-watchdog"]);
const watchdog = createHubLinkWatchdog({
stallDeadlineMs: 60_000,
Expand Down Expand Up @@ -217,6 +244,7 @@ const orchestrator = createSidecarOrchestrator({
// supervisor spawns, against the unwrapped substrate so the restore is
// never echoed back to the Hub as a new sidecar-authored update.
applyWorkflowRunPack: restoreWorkflowRunPack,
workflowProbeExecutor,
// Called from every connection's open handler -- the watchdog's
// aliveness signal -- and from the close path, which immediately
// re-schedules a reconnect that re-arms the deadline.
Expand Down
215 changes: 215 additions & 0 deletions apps/sidecar/src/source-asset-delivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Sidecar-side delivery of a workflow closure's source assets.
//
// A `WorkflowSourceAssetMount` carries a git pack for one hub asset. How that
// pack is materialized depends on how the closure references the asset:
// - a tarball-format entry reads a `.tgz` blob from a plain-file checkout
// (`applyAssetPack`), keyed by an `assetId -> mountPath` map; and
// - a source-format entry checks a subtree out of the git objects, so the
// pack is indexed into a RETAINED `.git` (a "gitDir"), keyed by an
// `assetId -> gitDir` map the loader hands `materializeGitEntry`.
// One asset can be referenced both ways; a source-format workflow closure
// references only source entries, so it produces only gitDirs.

import fsp from "node:fs/promises";
import path from "node:path";

import { getLogger } from "@intx/log";
import { base64Decode } from "@intx/types";
import { applyAssetPack } from "@intx/hub-agent";
import {
DEFAULT_PACK_MATERIALIZATION_LIMITS,
indexPackIntoGitDir,
} from "@intx/storage-isogit/node";
import type { WorkflowSourceAssetMount } from "@intx/types/sidecar";
import type { ToolPackageManifest } from "@intx/types/tool-packages";

const logger = getLogger(["sidecar", "source-asset-delivery"]);

const SAFE_ASSET_ID = /^[a-zA-Z0-9_.-]+$/;

/**
* Index a delivered asset pack into `gitDir` and RETAIN the object store, so a
* source subtree can be checked out from it. Builds into a sibling temp `.git`
* and RENAMES it into place, so the durable store is complete-or-absent: a crash
* mid-materialization leaves only the temp, never a partial `gitDir` that the
* dir-exists check `resolveDeploymentAssetMounts` runs on restore would trust.
* The rename is same-filesystem (the temp is a sibling under `gitDir`'s parent).
*
* On a rename conflict (a stale `gitDir` from a torn prior attempt) the freshly
* built store wins: the existing dir is removed and the temp renamed over it, so
* a re-delivery at a new commit never keeps the old content. A secondary rm
* failure is logged so it does not silently mask state; the primary error is
* rethrown.
*/
export async function indexAssetPackIntoGitDir(args: {
pack: Uint8Array;
commitSha: string;
gitDir: string;
}): Promise<void> {
const { pack, commitSha, gitDir } = args;
const parent = path.dirname(gitDir);
await fsp.mkdir(parent, { recursive: true });
const tempDir = await fsp.mkdtemp(path.join(parent, ".indexing-"));

const cleanupTemp = async (): Promise<void> => {
await fsp.rm(tempDir, { recursive: true, force: true }).catch((rmErr) => {
const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
logger.warn`source-asset temp gitdir cleanup failed at ${tempDir}: ${rmMsg}`;
});
};

try {
await indexPackIntoGitDir(
tempDir,
pack,
commitSha,
DEFAULT_PACK_MATERIALIZATION_LIMITS,
);
} catch (err) {
await cleanupTemp();
throw err;
}

try {
await fsp.rename(tempDir, gitDir);
} catch (err) {
// Only a "destination already exists" failure means a torn prior attempt we
// may supersede; any other rename error (EXDEV, EACCES, ENOSPC, EIO) must
// NOT destroy a possibly-good prior store -- clean up only the fresh temp
// and surface it.
if (!isDestinationExistsError(err)) {
await cleanupTemp();
throw err;
}
// The final path already holds a store (a torn prior attempt): rebuild
// wins, so drop the stale store and rename the fresh one over it.
await fsp.rm(gitDir, { recursive: true, force: true });
try {
await fsp.rename(tempDir, gitDir);
} catch (retryErr) {
await cleanupTemp();
throw retryErr;
}
}
}

/** Whether `err` is a rename failure caused by a non-empty destination. */
function isDestinationExistsError(err: unknown): boolean {
if (err === null || typeof err !== "object" || !("code" in err)) return false;
const code = String(err.code);
return code === "ENOTEMPTY" || code === "EEXIST" || code === "EISDIR";
}

/**
* The materialization format(s) each asset id is referenced with in `closure`.
* An asset with any tarball entry needs a plain-file checkout; an asset with
* any source entry needs a gitDir.
*/
export function assetReferenceFormats(
closure: ToolPackageManifest,
): Map<string, { tarball: boolean; source: boolean }> {
const byAsset = new Map<string, { tarball: boolean; source: boolean }>();
for (const entry of closure.entries) {
if (entry.source.kind !== "asset") continue;
const existing = byAsset.get(entry.source.assetId) ?? {
tarball: false,
source: false,
};
if (entry.source.package.format === "tarball") existing.tarball = true;
else existing.source = true;
byAsset.set(entry.source.assetId, existing);
}
return byAsset;
}

/** The absolute gitDir a source asset's objects are indexed into. */
export function sourceAssetGitDir(gitDirRoot: string, assetId: string): string {
// Reject an all-dots assetId (".", "..", ...) before the join. SAFE_ASSET_ID
// permits "." as a character, so a bare ".." would otherwise escape the
// per-asset dir (`path.join(root, "..")` is root's parent) and "." would
// resolve to the shared root itself. Mirrors `applyAssetPack`'s all-dots
// segment guard.
if (!SAFE_ASSET_ID.test(assetId) || /^\.+$/.test(assetId)) {
throw new Error(
`source-asset delivery: unsafe assetId ${JSON.stringify(assetId)}`,
);
}
return path.join(gitDirRoot, assetId);
}

/**
* The single cap on the total inline (base64) source-asset payload a workflow
* closure may deliver in one frame. Both the probe and the deploy pass this to
* `materializeWorkflowAssets`; a git-sourced asset that grows past it is the
* signal to move that path's asset delivery to a streamed transfer. One
* constant so the two paths cannot drift.
*/
export const MAX_INLINE_ASSET_PAYLOAD_BYTES = 32 * 1024 * 1024;

/**
* Materialize a workflow closure's delivered source assets: for each asset,
* check out plain tarball files under `assetRoot` (if the closure has tarball
* entries for it) and/or index the pack into a gitDir under `gitDirRoot` (if it
* has source entries). Returns both maps for the loader.
*/
export async function materializeWorkflowAssets(args: {
assets: readonly WorkflowSourceAssetMount[];
closure: ToolPackageManifest;
assetRoot: string;
gitDirRoot: string;
maxAssetPayloadBytes: number;
}): Promise<{
assetMounts: ReadonlyMap<string, string>;
gitDirs: ReadonlyMap<string, string>;
}> {
const formats = assetReferenceFormats(args.closure);
const assetMounts = new Map<string, string>();
const gitDirs = new Map<string, string>();
const seen = new Set<string>();
let totalPayloadBytes = 0;
for (const asset of args.assets) {
totalPayloadBytes += asset.pack.length;
if (totalPayloadBytes > args.maxAssetPayloadBytes) {
throw new Error(
`workflow source-asset materialization: inline asset payload exceeds the ${String(args.maxAssetPayloadBytes)}-byte cap`,
);
}
if (seen.has(asset.assetId)) {
throw new Error(
`workflow source-asset materialization: asset ${JSON.stringify(asset.assetId)} is delivered more than once`,
);
}
seen.add(asset.assetId);
// The frame delivers one mount per asset the closure references, so a
// delivered asset with no closure entry is a hub/frame inconsistency.
// Fail loud rather than silently ignore it (while still counting its
// payload toward the cap above).
const refs = formats.get(asset.assetId);
if (refs === undefined) {
throw new Error(
`workflow source-asset materialization: asset ${JSON.stringify(asset.assetId)} is delivered but referenced by no closure entry`,
);
}
const pack = base64Decode(asset.pack);
if (refs.tarball) {
await applyAssetPack({
workspaceRoot: args.assetRoot,
mountPath: asset.mountPath,
pack,
ref: asset.ref,
commitSha: asset.commitSha,
});
assetMounts.set(asset.assetId, asset.mountPath);
}
if (refs.source) {
const gitDir = sourceAssetGitDir(args.gitDirRoot, asset.assetId);
await indexAssetPackIntoGitDir({
pack,
commitSha: asset.commitSha,
gitDir,
});
gitDirs.set(asset.assetId, gitDir);
}
}
return { assetMounts, gitDirs };
}
Loading
Loading