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
4 changes: 2 additions & 2 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"@corbits/agent-directory": "workspace:*",
"@corbits/agent-runtime": "workspace:*",
"@corbits/approvals": "workspace:*",
"@corbits/artifacts": "github:corbitsdev/corbits-artifacts#dc435ba749b4378f19e5de772159c2a57e932938",
"@corbits/artifacts-hub": "workspace:*",
"@corbits/artifact-ui": "workspace:*",
"@corbits/artifacts": "github:corbitsdev/corbits-artifacts#5309a15d4aa504f132c54b4da08bb672b4903873",
"@corbits/attio-task-agent-workflow": "workspace:*",
"@corbits/chat": "workspace:*",
"@corbits/code-review-workflow": "workspace:*",
Expand Down
80 changes: 66 additions & 14 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,17 @@ import {
readProcessProvisionerConfig,
type ProcessProvisionerRole,
} from "@corbits/process-provisioner";
import { createWorkflowRunAuthenticator } from "@corbits/artifacts-hub";
import {
InlineContentStore,
mountArtifacts,
mountWorkflowArtifacts,
runArtifactMigrations,
type WorkflowArtifactEnv,
} from "@corbits/artifacts";
import {
artifactMatchesLibraryKindSegment,
LIBRARY_KIND_SEGMENTS,
} from "@corbits/artifact-ui/kind-filter";
import {
createConnectionRoutes,
isInferenceProvider,
Expand Down Expand Up @@ -223,7 +233,10 @@ import {
createInFlightRequestTracker,
withInFlightRequestTracking,
} from "./in-flight-requests";
import { withWorkflowRunTenantAuth } from "./workflow-run-tenant-auth";
import {
createWorkflowRunAuthenticator,
withWorkflowRunTenantAuth,
} from "./workflow-run-tenant-auth";

// Host policy constants, not configuration.
const MAX_TARBALL_BYTES = 10 * 1024 * 1024;
Expand Down Expand Up @@ -674,6 +687,10 @@ export async function createHub(config: HubConfig) {
},
) => void;
} = {};
// Package-owned artifacts tables (CL-8188): idempotent, advisory-locked,
// safe on every boot of every replica — see @corbits/artifacts' README.
await runArtifactMigrations(db);
const artifactContentStore = InlineContentStore;
const baseEventCollectors = createEventCollectorRegistry({
db: withTurnPartWriteDefaults(db),
// CL-7418: deliberately no terminal-status settle here. The folded
Expand Down Expand Up @@ -1468,18 +1485,53 @@ export async function createHub(config: HubConfig) {
`${TENANT_PREFIX}/inbox`,
createInboxRoutes({ db: mailboxDb, bus: mailboxBus }),
);
// CL-8160: the Workbench-composed workflow detail route
// (`@corbits/workflows`'s former `./detail/detail-route.ts`, mounted
// here at this same `/workflows/definitions` prefix as the vendored
// `createWorkflowDefinitionRoutes`) and the scheduled-workflow routes
// (`./schedule/scheduled-route.ts`: list/run-now/available-catalog) are
// both deleted per the owner ruling that workflow detail and schedule
// state derive from stock `@intx/hub-api` reads, not a hub-composed
// route. Stock's `GET /workflows/definitions` (mounted below by
// `createWorkflowDefinitionRoutes`) does not yet expose enough to
// reconstruct what these routes gave (no manifest/package metadata, no
// wire projection, no grant snapshot) — see the PR for CL-8160 for the
// upstream ask.
// Library/artifacts plane (CL-8188): `@corbits/artifacts` mounted directly
// rather than through the retired `@corbits/artifacts-hub` wrapper — the
// library now owns counts, preview, and every other Library HTTP surface
// this app previously reimplemented. `countSegments` keeps the segment
// taxonomy (what makes an artifact a "document" or a "routine") host-owned,
// per the library's README; the predicates themselves live in
// `@corbits/artifact-ui` so the web Files nav and this count walk share one
// mapping.
{
const artifactsApi = new Hono<TenantEnv>();
mountArtifacts(artifactsApi, {
db,
contentStore: artifactContentStore,
requireGrant: createRequireGrant({
grantStore: chatGrantStore,
conditionRegistry: grantConditionRegistry,
}),
countSegments: Object.fromEntries(
LIBRARY_KIND_SEGMENTS.map((segment) => [
segment,
(row: { kind: string; title: string }) =>
artifactMatchesLibraryKindSegment(row, segment),
]),
),
});
app.route(TENANT_PREFIX, artifactsApi);
}
// Myra's own artifact tools (`@corbits/artifact-tools`, CL-6000) and every
// Routine's own `finalize-tool`/`artifact-client` duplicate: the
// workflow-run-authenticated counterpart to the tenant-session mount just
// above, at the same `/api/workflow-artifacts` prefix the sidecar's
// `hubArtifactsUrl` already points at. Every route lives under this
// mount's own `/artifacts` prefix per `@corbits/artifacts`' fixed shape
// (`POST /artifacts`, `GET /artifacts/recent`, `GET /artifacts/:id`,
// `POST /artifacts/binary`) — callers reach it at
// `/api/workflow-artifacts/artifacts...`.
{
const workflowArtifactsApi = new Hono<WorkflowArtifactEnv>();
const workflowRunAuthenticator = createWorkflowRunAuthenticator({ db });
mountWorkflowArtifacts(workflowArtifactsApi, {
db,
contentStore: artifactContentStore,
resolveRunScope: (token, runAddress) =>
workflowRunAuthenticator.resolve(token, runAddress),
});
app.route("/api/workflow-artifacts", workflowArtifactsApi);
}
{
const mailboxApp = new Hono<TenantEnv>();
mountMailbox(mailboxApp, {
Expand Down
56 changes: 56 additions & 0 deletions apps/hub/src/workflow-run-tenant-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@
// before. The `:tenantId` path segment is never trusted: the middleware sets
// the tenant from the authenticated run's own scope, so a run cannot widen
// its reach by naming another tenant in the URL.
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { sha256 } from "@intx/crypto";
import type { DB } from "@intx/db";
import { sidecar, workflowRun } from "@intx/db/schema";
import {
createWorkflowRunDeployAuth,
type AppEnv,
Expand All @@ -34,6 +37,59 @@ export type WorkflowRunTenantAuthDeps = {
authenticator: WorkflowRunAuthenticator;
};

/**
* The tenant + principal + run a presented sidecar token and run address
* resolve to. This is the one concrete implementation of the
* `WorkflowRunAuthenticator` shape every workflow-run-authenticated surface
* in this app takes structurally (agent-directory, chat, connections,
* memory-hub, `@corbits/artifacts`' `mountWorkflowArtifacts`, and the
* `withWorkflowRunTenantAuth` wrap below) — a single sidecar-token +
* run-address check, reused everywhere rather than re-verified per surface.
*/
export type ResolvedWorkflowRunScope = {
readonly tenantId: string;
readonly principalId: string;
readonly runId: string;
};

export type CreateWorkflowRunAuthenticatorDeps = {
db: DB["db"];
};

export type ConcreteWorkflowRunAuthenticator = {
resolve(
token: string,
runAddress: string,
): Promise<ResolvedWorkflowRunScope | null>;
};

export function createWorkflowRunAuthenticator(
deps: CreateWorkflowRunAuthenticatorDeps,
): ConcreteWorkflowRunAuthenticator {
return {
async resolve(token, runAddress) {
if (token === "" || runAddress === "") return null;

const tokenHash = await sha256(token);
const sidecarRow = await deps.db.query.sidecar.findFirst({
where: eq(sidecar.tokenHashSha256, tokenHash),
});
if (sidecarRow === undefined) return null;

const run = await deps.db.query.workflowRun.findFirst({
where: eq(workflowRun.address, runAddress),
});
if (run === undefined || run.principalId === null) return null;

return {
tenantId: run.tenantId,
principalId: run.principalId,
runId: run.id,
};
},
};
}

export function withWorkflowRunTenantAuth(
nativeApp: Hono<AppEnv>,
deps: WorkflowRunTenantAuthDeps,
Expand Down
15 changes: 10 additions & 5 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,21 @@ export const ArtifactListItemSchema = type({
updatedAt: "string",
});
export const ArtifactListPageSchema = type({
data: ArtifactListItemSchema.array(),
artifacts: ArtifactListItemSchema.array(),
nextCursor: "string | null",
});
export const ArtifactDetailSchema = ArtifactListItemSchema.merge(
export const ArtifactRowSchema = ArtifactListItemSchema.merge(
type({
content: "string",
}),
);
// GET /api/tenants/:id/artifacts/:artifactId wraps the row in an `artifact`
// envelope key (`@corbits/artifacts`' `mountArtifacts`, CL-8188).
export const ArtifactDetailSchema = type({
artifact: ArtifactRowSchema,
});
export const ArtifactUploadResponseSchema = type({
data: ArtifactDetailSchema.array(),
artifacts: ArtifactRowSchema.array(),
});

// GET /api/tenants/:id/artifacts/counts — honest per-kind-segment counts
Expand All @@ -79,7 +84,7 @@ export type Approval = typeof ApprovalResponse.infer;
export type AssetRow = typeof AssetWithOriginResponse.infer;
export type ArtifactListItem = typeof ArtifactListItemSchema.infer;
export type ArtifactListPage = typeof ArtifactListPageSchema.infer;
export type ArtifactDetail = typeof ArtifactDetailSchema.infer;
export type ArtifactDetail = typeof ArtifactRowSchema.infer;
export type ArtifactCounts = typeof ArtifactCountsSchema.infer;
/**
* The envelope paginatedSchema validates, stated structurally: the generic
Expand Down Expand Up @@ -247,5 +252,5 @@ export async function fetchArtifactDetail(
`Unexpected artifact response shape: ${parsed.summary}`,
);
}
return parsed;
return parsed.artifact;
}
2 changes: 1 addition & 1 deletion apps/web/src/command-palette-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export function CommandPaletteProvider({
const libraryItems = useMemo<readonly PaletteResultItem[]>(
() =>
artifactsQuery.kind === "ready"
? artifactsQuery.data.data.map((artifact) => ({
? artifactsQuery.data.artifacts.map((artifact) => ({
id: `entity:library:${artifact.id}`,
title: artifact.title,
subtitle: artifactKindLabel(artifact.kind),
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/pages/library-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ export function LibraryRoute({ path }: { readonly path: string }) {
// Drop selection when the filtered list no longer contains the id.
useEffect(() => {
if (selectedId === null || page.kind !== "ready") return;
const stillThere = mapArtifactListToSummaries(page.data.data)
const stillThere = mapArtifactListToSummaries(page.data.artifacts)
.filter((row) => artifactMatchesLibraryKindSegment(row, kindSegment))
.some((row) => row.id === selectedId);
if (!stillThere) setSelectedId(null);
Expand Down Expand Up @@ -796,8 +796,8 @@ export function LibraryRoute({ path }: { readonly path: string }) {
return (
<QueryView query={page} label="your files" skeleton="rows">
{(rows) => {
const artifacts = mapArtifactListToSummaries(rows.data).filter((row) =>
artifactMatchesLibraryKindSegment(row, kindSegment),
const artifacts = mapArtifactListToSummaries(rows.artifacts).filter(
(row) => artifactMatchesLibraryKindSegment(row, kindSegment),
);
return (
<LibraryPage
Expand All @@ -812,7 +812,7 @@ export function LibraryRoute({ path }: { readonly path: string }) {
onQueryChange={setSearchQuery}
selectedId={selectedId}
onSelect={setSelectedId}
preview={detail.kind === "ready" ? detail.data : null}
preview={detail.kind === "ready" ? detail.data.artifact : null}
previewLoading={detail.kind === "loading" && selectedId !== null}
previewError={
detail.kind === "error" && selectedId !== null
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/shell/library-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ export async function uploadArtifactFiles(
);
}
const body = (await response.json()) as {
data?: readonly ArtifactDetail[];
artifacts?: readonly ArtifactDetail[];
};
if (!Array.isArray(body.data)) {
if (!Array.isArray(body.artifacts)) {
throw new ApiQueryError("Unexpected response shape from artifact upload.");
}
return body.data;
return body.artifacts;
}

/**
Expand Down
6 changes: 4 additions & 2 deletions apps/web/test/library-artifact-deep-link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ function routeFetch(input: RequestInfo | URL): Promise<Response> {
}
if (url.includes("/artifacts/art_1")) {
return Promise.resolve(
jsonResponse({ ...listedArtifact, content: "# Q3\nGrowth is up." }),
jsonResponse({
artifact: { ...listedArtifact, content: "# Q3\nGrowth is up." },
}),
);
}
if (url.includes("/artifacts")) {
return Promise.resolve(
jsonResponse({ data: [listedArtifact], nextCursor: null }),
jsonResponse({ artifacts: [listedArtifact], nextCursor: null }),
);
}
return Promise.reject(new Error(`unrouted fetch in library test: ${url}`));
Expand Down
10 changes: 7 additions & 3 deletions apps/web/test/library-upload-confirmation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe("Files upload confirmation", () => {
if (url.includes("/artifacts/upload") && init?.method === "POST") {
return Promise.resolve(
jsonResponse({
data: [
artifacts: [
{
id: "art_new",
kind: "document",
Expand All @@ -126,7 +126,9 @@ describe("Files upload confirmation", () => {
);
}
if (url.includes("/artifacts")) {
return Promise.resolve(jsonResponse({ data: [], nextCursor: null }));
return Promise.resolve(
jsonResponse({ artifacts: [], nextCursor: null }),
);
}
return Promise.reject(new Error(`unrouted fetch: ${url}`));
}) as typeof fetch;
Expand All @@ -153,7 +155,9 @@ describe("Files upload confirmation", () => {
return Promise.resolve(jsonResponse({ error: "boom" }, 500));
}
if (url.includes("/artifacts")) {
return Promise.resolve(jsonResponse({ data: [], nextCursor: null }));
return Promise.resolve(
jsonResponse({ artifacts: [], nextCursor: null }),
);
}
return Promise.reject(new Error(`unrouted fetch: ${url}`));
}) as typeof fetch;
Expand Down
Loading
Loading