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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ HUB_STATIC_DIR=../web/dist
# feature it configures off; the hub never treats a partially-set group
# as configured — it fails loudly at boot instead.

# The tenant every self-served personal bench is parented under. Leave
# unset: the operator tenant it would parent under does not exist as
# real infrastructure yet, so self-served benches are unparented
# top-level tenants until it does.
# The tenant every self-served personal bench is parented under.
# `workbench setup` writes this to the org tenant it created. Leave it
# blank only for isolated tests that need an unparented personal bench
# — that is not the default self-serve story. Restart the hub after
# setup so it reads the new value.
# OPERATOR_TENANT_ID=

# Per-IP rate limit on email sign-up. Defaults to 5 sign-ups per 60
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const HubEnv = type({
"a directory of built user-interface files the hub serves, e.g. apps/hub/public",
),
"OPERATOR_TENANT_ID?": type("string > 0").describe(
"the tenant id every self-served personal bench is parented under; optional because the operator tenant it would parent under does not exist as infrastructure yet, and this field lets that land later without a rename",
"the tenant id every self-served personal bench is parented under; workbench setup writes this for the org tenant it creates. Leave unset only for isolated tests that need an unparented personal bench",
),
"SIGNUP_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe(
"the per-IP sign-up rate-limit window, in seconds, e.g. 60",
Expand Down
10 changes: 5 additions & 5 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,11 +368,11 @@ const MAX_TARBALL_BYTES = 10 * 1024 * 1024;
// registries on a name collision — the platform-native alternative to
// npm publishing the CL-5999 capability audit called for. Routing the
// `@corbits` scope at this registry name means a `@corbits/*` pin
// resolves only once an operator seeds a `package-registry` asset named
// `CORBITS_TOOLS_REGISTRY` with the package's tarball — `workbench
// seed`'s `seedTenant` does exactly that, via
// `@corbits/tool-registry-publish`, ahead of deploying any workflow
// that pins a `@corbits/*` package; until then, resolution fails loud
// resolves only once an operator publishes a `package-registry` asset
// named `CORBITS_TOOLS_REGISTRY` with the package's tarball —
// `workbench setup` does exactly that onto the root tenant via
// `@corbits/tool-registry-publish`; descendants inherit it, and
// `seedTenant` does not pack. Until then, resolution fails loud
// rather than silently falling through to npmjs (which could never
// carry an unpublished scope anyway).
const TENANT_PREFIX = "/api/tenants/:tenantId";
Expand Down
16 changes: 8 additions & 8 deletions docs/TENANCY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ requires an upstream Interchange change. **Do not patch `vendor/intx`.**

## What already works (consume, do not reimplement)

| Capability | Where |
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Tenant `parentId` hierarchy | `@intx/db` tenant table; POST `/api/tenants` accepts `parentId` |
| Live ancestor-chain inheritance | `getAncestorChain` in `@intx/db` — catalog, credentials, providers walk ancestors at read time |
| Descendant walk | `getDescendantTenants` in `@intx/db` |
| Roles | Interchange native `owner` / `admin` / `member` — mirror 1:1 in UI; never invent a parallel role table |
| Personal bench parenting | `packages/onboarding` parents under `OPERATOR_TENANT_ID` when set |
| Memberships | Native principal + membership routes |
| Capability | Where |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Tenant `parentId` hierarchy | `@intx/db` tenant table; POST `/api/tenants` accepts `parentId` |
| Live ancestor-chain inheritance | `getAncestorChain` in `@intx/db` — catalog, credentials, providers walk ancestors at read time |
| Descendant walk | `getDescendantTenants` in `@intx/db` |
| Roles | Interchange native `owner` / `admin` / `member` — mirror 1:1 in UI; never invent a parallel role table |
| Personal bench parenting | `packages/onboarding` parents under `OPERATOR_TENANT_ID`; `workbench setup` writes that id for the org tenant it creates |
| Memberships | Native principal + membership routes |

Inheritance is **live**. Creating a sub-workbench must **not** copy
catalog rows, credentials, or providers from the parent — resolution
Expand Down
9 changes: 5 additions & 4 deletions docs/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@ applies what hasn't already run.
A workflow that pins a `@corbits/*` tool package (e.g. **assistant** pinning
`@corbits/memory-tools`) resolves that pin from a `package-registry` asset
(`CORBITS_TOOLS_REGISTRY`) carrying the package's tarball, built by
`@corbits/tool-registry-publish`. `bun run seed` (`packages/hub-client/src/seed.ts`)
republishes that tarball every time it runs, so after changing a tool
package's source, republish and redeploy with:
`@corbits/tool-registry-publish`. `workbench setup` publishes that tarball
onto the root tenant (descendants inherit it); `workbench seed` does not
pack. After changing a tool package's source, bump its version, then
republish with:

```sh
bun run seed
workbench setup
```

This is safe to re-run. Changing a tool package's source requires bumping
Expand Down
18 changes: 10 additions & 8 deletions docs/local-rip.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,16 @@ The **assistant** default workflow pins the `@corbits/memory-tools` tool
package (`workflows/assistant/src/index.ts`), and that pin only resolves
once a `package-registry`-kind asset named `corbits-tools` carries its
tarball (see `apps/hub/src/index.ts`'s `CORBITS_TOOLS_REGISTRY` comment).
`seedTenant` (`packages/hub-client/src/seed.ts`) publishes that asset
itself — via `@corbits/tool-registry-publish`, which bundles
`@corbits/memory-tools` into a self-contained tarball (every dependency
inlined, so the closure resolver has nothing further to fetch) and pushes
it through the hub's native asset REST routes — ahead of deploying any
workflow, so this step needs nothing from you: **echo**,
**workbench-digest**, and **assistant** all come up live.
`scripts/e2e/local-rip.test.ts` asserts exactly that.
`workbench setup` publishes that asset onto the root tenant via
`@corbits/tool-registry-publish` (bundles `@corbits/memory-tools` into a
self-contained tarball and pushes it through the hub's native asset REST
routes). Descendants inherit it; `seedTenant` does not pack. Isolated
tests leave `OPERATOR_TENANT_ID` blank so the walkthrough's personal
bench is itself the root — then the same publish happens once onto that
bench, and **echo**, **workbench-digest**, and **assistant** all come up
live. `scripts/e2e/local-rip.test.ts` asserts exactly that. The default
self-serve story is the other way: setup writes `OPERATOR_TENANT_ID` for
the org tenant, and first-login personal benches parent under it.

## 5. Check the Connections surface

Expand Down
12 changes: 7 additions & 5 deletions docs/seed-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,14 @@ first checking `GET /api/tenants/:id/skills/:name`.
"already exists" as done, not as a reason to abort the run the
hub's own error advice told the operator to re-run.

## Tool registry publish (`workbench seed`, ahead of every workflow deploy)
## Tool registry publish (`workbench setup`, onto the root tenant)

`publishCorbitsToolsRegistry` (`packages/tool-registry-publish/src/publish.ts`)
finds-or-creates the tenant's `corbits-tools` package-registry asset,
then PUTs whatever tarball is missing. Two properties keep a failed
publish from stranding a usable-looking-but-empty asset:
then PUTs whatever tarball is missing. `workbench setup` calls this
onto the root tenant so descendants inherit tarballs; `workbench seed`
does not pack. Two properties keep a failed publish from stranding a
usable-looking-but-empty asset:

- `checkToolPackageFreshness` runs **before** the asset is ever
created — a version-bump violation aborts the publish with no HTTP
Expand All @@ -96,8 +98,8 @@ publish from stranding a usable-looking-but-empty asset:
so a re-run of `publishCorbitsToolsRegistry` treats it exactly like
a brand-new registry and pushes every package, which is what
actually creates the repo's first commit. Repairing a tenant with
this history is the same operation as seeding one for the first
time: re-run `workbench seed`.
this history is the same operation as publishing the registry for
the first time: re-run `workbench setup`.

## Workflow deployments (`workbench seed`)

Expand Down
11 changes: 7 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ hosted provisioning, and every one of them is safe to re-run.
a single arktype schema, and every missing/malformed variable is
reported at once with the exact fix.
- `setup.ts` — `workbench setup`: initializes the database, provisions
the bench through the hub's native tenant-creation route, and reports
the role defaults the platform created.
the bench through the hub's native tenant-creation route, writes
`OPERATOR_TENANT_ID` for that org tenant so first-login benches parent
under it, publishes the platform `corbits-tools` registry onto that
root tenant (descendants inherit it), and reports the role defaults
the platform created.
- `seed.ts` — `workbench seed`: authenticates as the administrator,
resolves the configured bench by slug, and seeds it with the default
workflow set.
resolves the configured bench by slug, and deploys the default
workflow set. It does not pack tarballs.
- `reset.ts` — `workbench reset`: tears down local state (platform
schema and on-disk asset directories) directly, without a hub call.
- `db-setup.ts` — child-process runners for the shared setup/reset
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/env-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import { persistEnvVar, upsertEnvAssignment } from "./env-file";

describe("upsertEnvAssignment", () => {
test("appends when the key is absent", () => {
expect(
upsertEnvAssignment(
"BASE_URL=http://localhost:3000\n",
"OPERATOR_TENANT_ID",
"ten_1",
),
).toBe("BASE_URL=http://localhost:3000\nOPERATOR_TENANT_ID=ten_1\n");
});

test("replaces an existing assignment", () => {
expect(
upsertEnvAssignment(
"OPERATOR_TENANT_ID=ten_old\nBASE_URL=x\n",
"OPERATOR_TENANT_ID",
"ten_1",
),
).toBe("OPERATOR_TENANT_ID=ten_1\nBASE_URL=x\n");
});

test("uncomments the example assignment rather than appending a second copy", () => {
expect(
upsertEnvAssignment(
"# OPERATOR_TENANT_ID=\nBASE_URL=x\n",
"OPERATOR_TENANT_ID",
"ten_1",
),
).toBe("OPERATOR_TENANT_ID=ten_1\nBASE_URL=x\n");
});

test("prefers an uncommented assignment when a commented example is also present", () => {
expect(
upsertEnvAssignment(
"# OPERATOR_TENANT_ID=\nOPERATOR_TENANT_ID=ten_old\n",
"OPERATOR_TENANT_ID",
"ten_1",
),
).toBe("# OPERATOR_TENANT_ID=\nOPERATOR_TENANT_ID=ten_1\n");
});
});

describe("persistEnvVar", () => {
test("creates the file when it is missing", async () => {
const dir = await mkdtemp(join(tmpdir(), "workbench-env-"));
const envPath = join(dir, ".env");
await persistEnvVar(envPath, "OPERATOR_TENANT_ID", "ten_1");
expect(await readFile(envPath, "utf8")).toBe("OPERATOR_TENANT_ID=ten_1\n");
});

test("updates an existing .env in place", async () => {
const dir = await mkdtemp(join(tmpdir(), "workbench-env-"));
const envPath = join(dir, ".env");
await writeFile(envPath, "BASE_URL=http://localhost:3000\n", "utf8");
await persistEnvVar(envPath, "OPERATOR_TENANT_ID", "ten_1");
expect(await readFile(envPath, "utf8")).toBe(
"BASE_URL=http://localhost:3000\nOPERATOR_TENANT_ID=ten_1\n",
);
});
});
54 changes: 54 additions & 0 deletions packages/cli/src/env-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Upsert a KEY=value assignment in a dotenv file. `workbench setup`
// uses this to persist OPERATOR_TENANT_ID into the repository `.env`
// after it creates the org tenant, so first-login provision can parent
// personal benches under it.

import { readFile, writeFile } from "node:fs/promises";

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/** Replace or append `key=value` in dotenv `contents`. */
export function upsertEnvAssignment(
contents: string,
key: string,
value: string,
): string {
const line = `${key}=${value}`;
const escaped = escapeRegExp(key);
const uncommented = new RegExp(`^[ \\t]*${escaped}=.*$`, "m");
if (uncommented.test(contents)) {
return contents.replace(uncommented, line);
}
const commented = new RegExp(`^#[ \\t]*${escaped}=.*$`, "m");
if (commented.test(contents)) {
return contents.replace(commented, line);
}
if (contents === "") return `${line}\n`;
const prefix = contents.endsWith("\n") ? contents : `${contents}\n`;
return `${prefix}${line}\n`;
}

/** Read `envPath` (or start empty if missing), upsert, and write back. */
export async function persistEnvVar(
envPath: string,
key: string,
value: string,
): Promise<void> {
let contents = "";
try {
contents = await readFile(envPath, "utf8");
} catch (cause) {
if (
!(cause instanceof Error) ||
!("code" in cause) ||
cause.code !== "ENOENT"
) {
throw cause;
}
}
const next = upsertEnvAssignment(contents, key, value);
if (next === contents) return;
await writeFile(envPath, next, "utf8");
}
3 changes: 3 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@workbench/hub-client";
import { readSeedConfig, readSetupConfig } from "./config";
import { createDbSetupRunner, createResetRunner } from "./db-setup";
import { persistEnvVar } from "./env-file";
import { runReset } from "./reset";
import { runSeed } from "./seed";
import { runSetup } from "./setup";
Expand Down Expand Up @@ -60,6 +61,8 @@ async function main(argv: string[]): Promise<void> {
config,
api: createHubAPI(config.hubUrl),
runDbSetup: createDbSetupRunner(REPO_ROOT),
persistEnv: ({ key, value }) =>
persistEnvVar(resolve(REPO_ROOT, ".env"), key, value),
log: out,
});
return;
Expand Down
12 changes: 4 additions & 8 deletions packages/cli/src/seed.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// `workbench seed`: authenticate as the administrator, resolve the
// configured bench by slug, and seed it with the default workflow set
// through `@workbench/hub-client`'s `seedTenant`. Safe to re-run; every
// skipped step says so.
// configured bench by slug, and deploy the default workflow set
// through `@workbench/hub-client`'s `seedTenant`. Does not pack
// tarballs — `workbench setup` publishes `corbits-tools` onto the
// root. Safe to re-run; every skipped step says so.

import { paginatedSchema, PrincipalSummary, TenantResponse } from "@intx/types";
import {
Expand All @@ -13,7 +14,6 @@ import {
DEFAULT_WORKFLOWS,
type ApiCall,
type DefaultWorkflow,
type ToolRegistryPublisher,
type WorkflowPusher,
} from "@workbench/hub-client";
import { CliError } from "@workbench/hub-client";
Expand All @@ -23,8 +23,6 @@ export type SeedDeps = {
config: SeedConfig;
api: ApiCall;
pushWorkflow: WorkflowPusher;
/** Passed through to `seedTenant`; a test double replaces the real corbits-tools publish the same way `pushWorkflow` replaces the real git push. */
publishToolRegistry?: ToolRegistryPublisher;
log: (line: string) => void;
sleep?: (ms: number) => Promise<void>;
runStartTimeoutMs?: number;
Expand Down Expand Up @@ -128,8 +126,6 @@ export async function runSeed(
log,
workflows: resolvedWorkflows,
};
if (deps.publishToolRegistry !== undefined)
seedArgs.publishToolRegistry = deps.publishToolRegistry;
if (deps.sleep !== undefined) seedArgs.sleep = deps.sleep;
if (deps.runStartTimeoutMs !== undefined)
seedArgs.runStartTimeoutMs = deps.runStartTimeoutMs;
Expand Down
Loading
Loading