Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
63 changes: 40 additions & 23 deletions VENDORED.md

Large diffs are not rendered by default.

59 changes: 12 additions & 47 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,7 @@ import {
createProviderHealthPort,
createProviderHealthStore,
} from "@corbits/connections/provider-health";
import {
createWorkflowAuthorRegistry,
createWorkflowAuthorRoutes,
listDeployedCronDefinitions,
SCHEDULE_TICK_CONTENT,
} from "@corbits/workflows";
import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { type Context, Hono, type Next } from "hono";
Expand All @@ -193,8 +188,6 @@ import {
type SidecarProvisionerConfig,
} from "./config";
import type { SidecarProvisioner } from "@intx/hub-sessions";
import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch";
import { createCronEmitter } from "@corbits/workflow-schedule/emitter";

// Host policy constants, not configuration.
const MAX_TARBALL_BYTES = 10 * 1024 * 1024;
Expand Down Expand Up @@ -789,8 +782,8 @@ export async function createHub(config: HubConfig) {
plugins: sidecarPlugins,
router: sidecarRouter,
hubWebSocketUrl,
onReady: async (allocation) => {
await workflowAllocationService.deployReadyAllocation(allocation);
onReady: async (allocation, reconciliation) => {
await workflowAllocationService.deployReadyAllocation(allocation, reconciliation);
await workflowDispatchService.requeueForReadyAllocation(allocation.anchorRunId);
},
});
Expand Down Expand Up @@ -1439,7 +1432,7 @@ export async function createHub(config: HubConfig) {
launchWebhookTrigger(
{
db,
sessionService,
sidecarRouter,
repoStore: agentRepoStore.repoStore,
workflowAllocationService,
credentialCipher,
Expand Down Expand Up @@ -1645,41 +1638,14 @@ export async function createHub(config: HubConfig) {
// Snooze is dropped (CL-8185): the unsnooze sweep and its `inbox.snooze`
// table are gone — see `packages/inbox/src/migrations.ts`'s forward-drop.

// Recurring auto-fire for schedule-triggered deployments. The cron and
// the tick arithmetic belong to `@corbits/workflow-schedule`; this root
// only supplies the two host bindings the emitter cannot own — which
// deployments are live, and how a tick fires. CL-8160 deleted the
// hub-mounted run-now route (`triggerNativeWorkflowRoutineRun`'s only
// other caller); this cron fire is the sole remaining caller now.
const cronEmitter = createCronEmitter({
listCronDeployments: async () =>
(await listDeployedCronDefinitions(db)).map((definition) => ({
...definition,
deploymentId: definition.definitionId,
})),
fire: async (definition) => {
await triggerNativeWorkflowRoutineRun(
{ db, sidecarRouter },
{
tenantId: definition.tenantId,
definitionId: definition.definitionId,
principalId: definition.creatorPrincipalId,
fromDomain: definition.tenantDomain,
content: SCHEDULE_TICK_CONTENT,
},
);
},
onError: (error, deployment) => {
reportError(error, {
operation: "workflow-schedule.cron-emitter",
extra: { deploymentId: deployment.deploymentId },
});
},
clock: () => new Date(),
});
void cronEmitter.rescan().catch((error: unknown) => {
reportError(error, { operation: "workflow-schedule.cron-emitter.boot" });
});
// CL-8206: the hub-computed cron auto-fire over a definition's frozen
// wire projection is gone — Interchange 79adc433 retired the
// projection storage `listDeployedCronDefinitions` read the cron
// trigger off of, with no replacement column. Per CL-8181's own
// direction (see the notify-to-reconnect comment above), a
// schedule-triggered routine is deployed like any other workflow now
// rather than driven by a hub-owned periodic loop; this hard cutover
// has no port to a stock equivalent.

app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir)));

Expand Down Expand Up @@ -1715,7 +1681,6 @@ export async function createHub(config: HubConfig) {
relaunchSweepSeries += 1;
clearTimeout(relaunchSweepTimer);
chatOrchestrator.dispose();
cronEmitter.stop();
await closeMailbox();
// The pool end waits on in-flight queries; a query whose socket
// died with the process must never stall shutdown, so bound it.
Expand Down
14 changes: 11 additions & 3 deletions apps/hub/src/native-workflow-routine-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,13 @@ describe("triggerNativeWorkflowRoutineRun", () => {
{
db: createFakeDb([LIVE_ANCHOR]) as never,
sidecarRouter: {
routeMail: (address: string, base64: string, messageId: string) => {
routeMailCalls.push({ address, base64, messageId });
routeMail: (
address: string,
base64: string,
authenticatedSender: string,
messageId: string,
) => {
routeMailCalls.push({ address, base64, authenticatedSender, messageId });
return true;
},
} as never,
Expand All @@ -80,8 +85,11 @@ describe("triggerNativeWorkflowRoutineRun", () => {
address: "wfr_anchor1@acme.hub.test",
});
expect(routeMailCalls).toHaveLength(1);
const [call] = routeMailCalls as [{ address: string; base64: string; messageId: string }];
const [call] = routeMailCalls as [
{ address: string; base64: string; authenticatedSender: string; messageId: string },
];
expect(call.address).toBe("wfr_anchor1@acme.hub.test");
expect(call.authenticatedSender).toBe("usr_1@acme.hub.test");
expect(typeof call.base64).toBe("string");
expect(call.base64.length).toBeGreaterThan(0);
});
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/native-workflow-routine-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export async function triggerNativeWorkflowRoutineRun(
const rawMessage = assembleMessage(headers, signedContent, signature);
const base64 = base64Encode(rawMessage);

const delivered = deps.sidecarRouter.routeMail(address, base64, messageId);
const delivered = deps.sidecarRouter.routeMail(address, base64, headers.from, messageId);
if (!delivered) {
throw new Error(
`native workflow deployment ${address} is not routable; cannot deliver routine's trigger mail`,
Expand Down
26 changes: 26 additions & 0 deletions apps/sidecar/src/atomic-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// exposing a torn record.

import { open, rename, unlink } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname } from "node:path";

import { getLogger } from "@intx/log";
Expand Down Expand Up @@ -77,3 +78,28 @@ export async function writeFileAtomicDurable(
logger.warn`parent-dir fsync failed for ${path}; durability is degraded but the file is renamed and fsynced — ${err instanceof Error ? err.message : String(err)}`;
}
}

/**
* Durably remove `path`: unlink, then fsync the parent directory so the
* removal survives a power loss (an unlink with no directory fsync can be
* resurrected after a crash, re-staling a cache the removal was meant to
* invalidate). A missing file is a no-op, not an error -- the caller may be
* evicting an entry that never made it to disk.
*/
export async function removeFileAtomicDurable(path: string): Promise<void> {
if (!existsSync(path)) return;
await unlink(path).catch(() => undefined);
try {
const dirHandle = await open(dirname(path), "r");
try {
await dirHandle.sync();
} finally {
await dirHandle.close();
}
} catch (err) {
// report-error-ignore: mirrors writeFileAtomicDurable's own degraded-durability
// warn above — the file is already unlinked, so this is a best-effort fsync,
// not a failed operation, and the caller has nothing actionable to catch.
logger.warn`parent-dir fsync failed removing ${path}; durability is degraded but the file is unlinked — ${err instanceof Error ? err.message : String(err)}`;
}
}
35 changes: 33 additions & 2 deletions apps/sidecar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,23 @@

import path from "node:path";
import { createEd25519Crypto, generateKeyPair, verifySSHSignature } from "@intx/crypto";
import { createSidecarOrchestrator, type HubLink } from "@intx/hub-agent";
import {
createSidecarOrchestrator,
createSenderKeyCache,
createSenderCryptoResolver,
createInboundMailPolicyRegistry,
createInboundMailPolicyLookup,
type HubLink,
} from "@intx/hub-agent";
import { createAgentRepoStore } from "@intx/hub-sessions";
import { loadAdapterRegistry } from "@intx/inference/providers";
import { getLogger, setup } from "@intx/log";
import { createInMemoryTransport } from "@intx/mail-memory";
import { createTarballCache } from "@intx/tool-packaging";
import { hexEncode } from "@intx/types";
import { hexDecode, hexEncode } from "@intx/types";

import { reportError } from "@corbits/error-sink";
import { removeFileAtomicDurable, writeFileAtomicDurable } from "./atomic-write";
import { readSidecarConfig } from "./config";
import { DEFAULT_TOOL_REGISTRIES_JSON, parseToolRegistries } from "./tool-materialization";
import { createWorkflowProbeExecutor } from "./workflow-probe-handler";
Expand Down Expand Up @@ -205,6 +213,21 @@ const workflowProbeExecutor = createWorkflowProbeExecutor({
// PKCE login for codex/xai-oauth on this machine when the hub requests it.
const oauthLoopbackLogin = createOAuthLoopbackLoginService();

// Sender-key cache backing inbound-mail signature verification: durably
// persists the hub-vouched public key per sender address so a later inbound
// frame can be checked against a locally cached key, not only the key the
// hub stamped on the frame. The registry starts empty; a deployment
// registers its resolved inbound-mail policy when it hydrates and
// unregisters it on teardown, so an unregistered (or torn-down) address
// falls through to the fully-closed default via `createInboundMailPolicyLookup`.
const senderKeyCache = await createSenderKeyCache({
dataDir: config.dataDir,
writeFileDurable: (writePath, contents) =>
writeFileAtomicDurable(writePath, contents, { mode: 0o600 }),
removeFileDurable: (removePath) => removeFileAtomicDurable(removePath),
});
const inboundMailPolicyRegistry = createInboundMailPolicyRegistry();

const watchdogLog = getLogger(["sidecar", "hub-link-watchdog"]);
const watchdog = createHubLinkWatchdog({
stallDeadlineMs: 60_000,
Expand Down Expand Up @@ -242,6 +265,14 @@ const orchestrator = createSidecarOrchestrator({
applyWorkflowRunPack: restoreWorkflowRunPack,
workflowProbeExecutor,
oauthLoginExecutor: oauthLoopbackLogin.start,
resolveSenderCrypto: createSenderCryptoResolver(senderKeyCache),
lookupInboundMailPolicy: createInboundMailPolicyLookup(inboundMailPolicyRegistry),
cacheSenderKey: async (address, publicKey) => {
await senderKeyCache.put(address, hexDecode(publicKey));
},
evictSenderKey: async (address) => {
await senderKeyCache.evict(address);
},
// 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
8 changes: 6 additions & 2 deletions apps/sidecar/src/workflow-host-wiring/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ export function createSidecarDeployRouter(deps: {
stepOrder,
definitionHash,
warmKeep,
onInferenceEvent: (event, childRunId) => {
onInferenceEvent: (event) => {
// The event arrives HMAC-verified over the child's event channel.
// Re-narrow it to the hub's `InferenceEvent` union; a parse
// failure means upstream corruption, so drop it loudly rather
Expand All @@ -1122,7 +1122,11 @@ export function createSidecarDeployRouter(deps: {
logger.warn`dropping workflow inference event for ${spec.agentAddress}: ${validated.summary}`;
return;
}
publishInferenceEvent(spec.agentAddress, validated, spec.sessionId, childRunId);
// This hook fires for the workflow's own top-level step events,
// never a spawned body's -- a body's events carry their own
// childRunId through the separate RunSuspendableChild onEvent
// seam, not this per-deployment onInferenceEvent.
publishInferenceEvent(spec.agentAddress, validated, spec.sessionId);
},
};

Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 1 addition & 29 deletions packages/agent-directory/src/one-shot-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,6 @@ function firstCall<T>(calls: readonly T[]): T {
return call;
}

// The inert projection the deploy freeze persists onto the definition's
// version row — the launch body's only hub-side source under the
// `workflow.json` retirement.
const AGENT_WIRE_PROJECTION = {
id: "wfd_planner",
triggers: [],
stepOrder: ["agent"],
steps: {
agent: {
kind: "step",
agent: {
systemPrompt: "You are Myra.",
modelSources: [{ provider: "anthropic", model: "declared-default-model" }],
},
},
},
};

const DEFINITION_ROW = {
id: "wfd_planner",
tenantId: "tnt_1",
Expand All @@ -45,22 +27,12 @@ const DEFINITION_ROW = {
};
const TENANT_ROW = { id: "tnt_1", domain: "acme.example" };

/** A `db` double covering both the row reads and the drizzle
* `select().from().where().limit()` chain `loadFrozenWireProjection`
* runs for the version row's stored projection. */
function fakeDb() {
return {
query: {
workflowDefinition: { findFirst: async () => DEFINITION_ROW },
tenant: { findFirst: async () => TENANT_ROW },
},
select: () => ({
from: () => ({
where: () => ({
limit: async () => [{ wireProjection: AGENT_WIRE_PROJECTION }],
}),
}),
}),
};
}

Expand Down Expand Up @@ -142,7 +114,7 @@ function createBaseDeps() {
db: fakeDb(),
repoStore: { resolveRef: async () => "sha_test" },
workflowAllocationService: {},
sessionService: {},
sidecarRouter: {},
isRoutable: () => true,
cryptoProviders: {
async get() {
Expand Down
Loading
Loading