@isomorph.ai/cli 0.9.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/harbour-cli/src/agent-setup.js +2 -1
- package/dist/packages/harbour-cli/src/dev.js +15 -4
- package/dist/packages/harbour-cli/src/guide.js +19 -4
- package/dist/packages/harbour-cli/src/jobs.js +8 -5
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +12 -12
- package/dist/packages/harbour-cli/src/local-runtime.js +111 -3
- package/package.json +2 -2
|
@@ -104,7 +104,7 @@ This guide is for one job: an app that runs on Isomorph (a folder with \`.isomor
|
|
|
104
104
|
|
|
105
105
|
The person may not be a developer: they say what they want in plain English; you build it and run every command (they type none but the sign-in below). Use \`--json\`; never paste JSON, logs or file contents at them.
|
|
106
106
|
|
|
107
|
-
Rules live beside this file: \`core.md\` before the first edit; \`integrations.md\`, \`ai.md\`, \`jobs.md\` before the work each names.
|
|
107
|
+
Rules live beside this file: \`core.md\` before the first edit; \`integrations.md\`, \`ai.md\`, \`jobs.md\`, \`server.md\` before the work each names.
|
|
108
108
|
|
|
109
109
|
## Getting ready
|
|
110
110
|
|
|
@@ -124,6 +124,7 @@ Rules live beside this file: \`core.md\` before the first edit; \`integrations.m
|
|
|
124
124
|
| "make it live" | only after they have tried the preview: \`isomorph promote --operation <ref> --app-root . --confirm-tested --json\`. Report the production link, or that operator approval is pending. |
|
|
125
125
|
| "stop it" | \`isomorph stop --app-root .\` (data kept); \`isomorph dev --app-root . --reset\` only when they ask to start over. |
|
|
126
126
|
| "every day at 2pm", "send this automatically" | read \`jobs.md\` beside this skill first (a job cannot email as a person); write the job, run it once with \`isomorph jobs run\`, and say its sentence. |
|
|
127
|
+
| "call this API", "a key for it", "run it in the background" | read \`server.md\` beside this skill first; server code goes in \`actions/\` or \`jobs/\`, a key by its name only. |
|
|
127
128
|
|
|
128
129
|
A refusal names its layer, its reason and its fix: change that one thing, then run it again. A wait timeout is not a failure — work is still running: run the command it prints (\`${continueCommand("<ref>")}\`); never start a second deploy while one is running.
|
|
129
130
|
|
|
@@ -3,10 +3,10 @@ import { closeSync, openSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, readFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { createForwarder } from "./forwarder.js";
|
|
6
|
-
import { readKitLock } from "./kit.js";
|
|
7
6
|
import { GovernanceClient, ensureLinkedApp, fileDeclaredRequests, IDENTITY_WORDS, renderGrantGroup } from "./integrations.js";
|
|
8
|
-
import { acquireDevLock, allocatePorts, assertMigrationNames, installDependencies, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, recordDevChildren, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
9
|
-
import { accessDigest, kitPaths } from "./kit.js";
|
|
7
|
+
import { acquireDevLock, allocatePorts, assertMigrationNames, installDependencies, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, recordDevChildren, releaseDevLock, runCommand, hasServerModules } from "./local-runtime.js";
|
|
8
|
+
import { accessDigest, kitPaths, readDeclaration, readKitLock } from "./kit.js";
|
|
9
|
+
import { startJobFixture } from "./job-fixture.js";
|
|
10
10
|
import { CliError, safeError } from "./output.js";
|
|
11
11
|
/**
|
|
12
12
|
* Signed in at startup: link the app and file the access request for every
|
|
@@ -95,10 +95,12 @@ export async function startDev(root, options) {
|
|
|
95
95
|
await acquireDevLock(root, ports);
|
|
96
96
|
let vite;
|
|
97
97
|
let forwarder;
|
|
98
|
+
let fixture;
|
|
98
99
|
const stop = async () => {
|
|
99
100
|
forwarder?.close();
|
|
100
101
|
vite?.kill("SIGTERM");
|
|
101
102
|
await runtime.stop();
|
|
103
|
+
await fixture?.close();
|
|
102
104
|
await releaseDevLock(root);
|
|
103
105
|
};
|
|
104
106
|
try {
|
|
@@ -119,11 +121,20 @@ export async function startDev(root, options) {
|
|
|
119
121
|
// table exists; nothing to create or restart.
|
|
120
122
|
if (watched > 0)
|
|
121
123
|
options.output(`Installed the Isomorph realtime outbox for ${watched} table(s) (isomorph.realtime change events).`);
|
|
124
|
+
await runtime.installJobRuns();
|
|
122
125
|
const origin = `http://127.0.0.1:${ports.origin}`;
|
|
126
|
+
// The app's server side: the kit runtime behind a loopback of its own in
|
|
127
|
+
// front of the session's gateway (job-fixture.ts: canned AI, company
|
|
128
|
+
// systems refused), exactly what `isomorph jobs run` gives one job.
|
|
129
|
+
if (await hasServerModules(root)) {
|
|
130
|
+
fixture = await startJobFixture({ upstreamPort: ports.gateway, real: false, declaration: await readDeclaration(root) });
|
|
131
|
+
if (await runtime.startKitRuntime(session, fixture.url, options.output))
|
|
132
|
+
options.output("Started the app's server side (actions/ and jobs/) on the kit runtime.");
|
|
133
|
+
}
|
|
123
134
|
const npm = nodePackageCommand("npm", ["run", "dev", "--", ...viteDevArguments(ports.vite)]);
|
|
124
135
|
vite = spawn(npm.command, npm.args, { cwd: root, env: { ...env, ISOMORPH_LOCAL_ORIGIN: origin, ISOMORPH_VITE_PORT: String(ports.vite) }, stdio: ["ignore", "inherit", "inherit"] });
|
|
125
136
|
vite.on("error", () => options.output("Vite could not be started; is npm installed and `npm install` done?"));
|
|
126
|
-
await recordDevChildren(root, { ...(vite.pid ? { vite: vite.pid } : {}), ...(runtime.gatewayPid ? { gateway: runtime.gatewayPid } : {}) });
|
|
137
|
+
await recordDevChildren(root, { ...(vite.pid ? { vite: vite.pid } : {}), ...(runtime.gatewayPid ? { gateway: runtime.gatewayPid } : {}), ...(runtime.runtimePid ? { runtime: runtime.runtimePid } : {}) });
|
|
127
138
|
const company = options.company;
|
|
128
139
|
let warnedAuth = false;
|
|
129
140
|
let lockAppId = (await readKitLock(root))?.appId || undefined;
|
|
@@ -13,7 +13,7 @@ export const GUIDE_MODULES = {
|
|
|
13
13
|
|
|
14
14
|
## The SDK is the only door
|
|
15
15
|
|
|
16
|
-
\`@isomorph.ai/app-sdk\` (exported by \`src/isomorph.client.ts\`) is the whole path: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`, \`isomorph.realtime.*\`, \`isomorph.integrations.execute\` (integrations.md), \`isomorph.ai.chat\` (ai.md). No database, bucket or company URL from browser code; no backend, auth library, deployment config, provider key or other SDK; no secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/\`, never \`.isomorph/local/\`.
|
|
16
|
+
\`@isomorph.ai/app-sdk\` (exported by \`src/isomorph.client.ts\`) is the whole path: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`, \`isomorph.realtime.*\`, \`isomorph.integrations.execute\` (integrations.md), \`isomorph.ai.chat\` (ai.md), \`isomorph.actions.invoke\` and \`isomorph.jobs.*\` (server.md). No database, bucket or company URL from browser code; no other backend, auth library, deployment config, provider key or other SDK; no secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/\`, never \`.isomorph/local/\`.
|
|
17
17
|
|
|
18
18
|
## Identity
|
|
19
19
|
|
|
@@ -82,7 +82,7 @@ A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explic
|
|
|
82
82
|
\`execute("company-slack", { …, mode: "app" })\` posts as the bot under \`slack.displayName\`/\`iconEmoji\` in \`.isomorph/app.json\`; \`mode: "user"\` posts as the person after consent (\`USER_RECONNECT_REQUIRED\`: offer Connect again). One mode per connection (\`MODE_REQUIRED\`); an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel; \`/invite @Isomorph AI\`.`,
|
|
83
83
|
ai: `# Governed AI
|
|
84
84
|
|
|
85
|
-
AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxTokens })\` call on the app's one client (\`src/isomorph.client.ts\`), never through a wrapper function (the gate reads only the direct call), behind a control the person presses — never on load, in an effect or a timer. Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the governed AI gateway holds the key and IT sees every call. Send \`messages\` and \`maxTokens\` and nothing else; a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has not enabled an AI provider yet: say so in one line and keep the app working without AI.
|
|
85
|
+
AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxTokens })\` call on the app's one client (\`src/isomorph.client.ts\`), never through a wrapper function (the gate reads only the direct call), behind a control the person presses when called from the browser — never on page load, in an effect or a browser timer; server code (server.md) may call it as the app. Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the governed AI gateway holds the key and IT sees every call. Send \`messages\` and \`maxTokens\` and nothing else; a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has not enabled an AI provider yet: say so in one line and keep the app working without AI.
|
|
86
86
|
|
|
87
87
|
\`\`\`ts
|
|
88
88
|
const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
|
|
@@ -93,9 +93,24 @@ The starter calls no AI; add the one call when the person asks. The check's gene
|
|
|
93
93
|
|
|
94
94
|
A job runs as the app, never as a person: Gmail and user-mode Slack cannot run in one; \`slack.message.post\` declared \`"identity": "app"\` and warehouse reads can. Say so before building a scheduled email.
|
|
95
95
|
|
|
96
|
-
Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment
|
|
96
|
+
Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment on the app's server process, which owns the real clock. A job may call \`isomorph.ai.chat\` and app-identity company operations. A job with no \`schedule\` runs only when enqueued (server.md). Test it immediately: start \`isomorph dev\`, then \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\` — local data, canned AI, company systems refused, nothing sent. Use \`--real\` only when the person explicitly asks to test the company action now and the exact action has development approval.
|
|
97
97
|
|
|
98
98
|
A real scheduled send requires the person's explicit request, the exact operation and destination declared in \`.isomorph/integrations.json\`, and a grant for that environment; use one deterministic idempotency key for the business period and destination so a replay does not silently repost.
|
|
99
99
|
|
|
100
|
-
Tell them: "Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved."
|
|
100
|
+
Tell them: "Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved."`,
|
|
101
|
+
server: `# Server code: actions, queued runs, outside APIs
|
|
102
|
+
|
|
103
|
+
Isomorph runs one server process for the app, started by \`isomorph dev\`, the check and the deployment. It serves \`actions/<name>.ts\` and \`jobs/<name>.ts\`; both use the same \`isomorph\` client and nothing else — no database driver, no \`process.env.DATABASE_URL\`, no HTTP framework.
|
|
104
|
+
|
|
105
|
+
## Actions
|
|
106
|
+
|
|
107
|
+
\`actions/<name>.ts\` exports one default async handler \`(input, ctx)\`; \`ctx.user\` is the signed-in person, \`ctx.tenant\` and \`ctx.app\` the app. The browser calls it as \`isomorph.actions.invoke("<name>", input)\` — a string literal, the file's name; the check refuses a name with no file. Names starting with \`isomorph-\` are Isomorph's own.
|
|
108
|
+
|
|
109
|
+
## Queued runs
|
|
110
|
+
|
|
111
|
+
For work the person starts and waits on: \`isomorph.jobs.enqueue("<job>", input)\` answers \`{ id }\`; \`isomorph.jobs.get(id)\` shows \`status\` (queued, running, succeeded, failed, cancelled), \`progress\`, \`note\`, \`result\`; \`isomorph.jobs.cancel(id)\`. The job's handler receives \`input\`, \`runId\`, \`progress(percent, note)\` and \`cancelled()\`; a failed run is retried three times. Poll \`get\` from the page to show progress.
|
|
112
|
+
|
|
113
|
+
## Outside APIs and keys
|
|
114
|
+
|
|
115
|
+
A key is read as \`process.env.<NAME>\` in server code only, its name listed in \`.env.example\`, never a value in source or in browser code. On this machine put \`NAME=value\` in \`.isomorph/local/secrets.env\`; after deployment Isomorph asks IT for the value once. Vendor SDKs (a payments or email client) follow the same rule from server code. Not available: a route the outside world calls (webhooks) and a sign-in flow with another provider — say so and ask before designing around them.`
|
|
101
116
|
};
|
|
@@ -2,11 +2,12 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { CliError } from "./output.js";
|
|
5
|
-
import { parseSessionEnv, readDevLock, runCommand } from "./local-runtime.js";
|
|
5
|
+
import { parseSessionEnv, readDevLock, readLocalSecrets, runCommand } from "./local-runtime.js";
|
|
6
6
|
import { kitPaths, readDeclaration } from "./kit.js";
|
|
7
7
|
import { personRefusal, runsAsPerson, startJobFixture } from "./job-fixture.js";
|
|
8
8
|
const jobName = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
9
9
|
const scheduleDeclaration = /^export\s+const\s+schedule\s*=\s*["']([^"']+)["']\s*;?\s*$/m;
|
|
10
|
+
const scheduleExport = /\bexport\s+const\s+schedule\b/;
|
|
10
11
|
export async function discoverJobs(root) {
|
|
11
12
|
const directory = join(root, "jobs");
|
|
12
13
|
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
@@ -19,12 +20,14 @@ export async function discoverJobs(root) {
|
|
|
19
20
|
throw new CliError("JOB_INVALID", `jobs/${entry.name}: the file name must be a lowercase logical name.`);
|
|
20
21
|
const path = join(directory, entry.name);
|
|
21
22
|
const source = await readFile(path, "utf8");
|
|
23
|
+
// A schedule is optional (a job without one runs only when enqueued);
|
|
24
|
+
// a declared one must be a literal, so the clock reads what was written.
|
|
22
25
|
const schedule = source.match(scheduleDeclaration)?.[1];
|
|
23
|
-
if (!schedule)
|
|
26
|
+
if (!schedule && scheduleExport.test(source))
|
|
24
27
|
throw new CliError("JOB_INVALID", `jobs/${entry.name}: export one literal schedule, for example export const schedule = \"0 9 * * 1-5\".`);
|
|
25
28
|
if (!/^export\s+default\s+/m.test(source))
|
|
26
29
|
throw new CliError("JOB_INVALID", `jobs/${entry.name}: export one default job handler.`);
|
|
27
|
-
jobs.push({ name, schedule, path });
|
|
30
|
+
jobs.push({ name, ...(schedule ? { schedule } : {}), path });
|
|
28
31
|
}
|
|
29
32
|
return jobs.sort((a, b) => a.name.localeCompare(b.name));
|
|
30
33
|
}
|
|
@@ -41,7 +44,7 @@ export async function runJob(root, name, scheduledAt, run = runCommand, real = f
|
|
|
41
44
|
const lock = await readDevLock(root);
|
|
42
45
|
if (!session || !lock)
|
|
43
46
|
throw new CliError("DEV_NOT_RUNNING", "Start `isomorph dev` before running a job so it uses the same local data and identity boundary.");
|
|
44
|
-
const env = parseSessionEnv(session);
|
|
47
|
+
const env = { ...parseSessionEnv(session), ...(await readLocalSecrets(root)) };
|
|
45
48
|
const runner = `const module = await import(${JSON.stringify(pathToFileURL(job.path).href)}); if (typeof module.default !== "function") throw new Error("job has no default handler"); await module.default({scheduledAt: process.env.ISOMORPH_SCHEDULED_AT});`;
|
|
46
49
|
// The job talks to a loopback origin of its own (job-fixture.ts) in front of
|
|
47
50
|
// the session's gateway, or of the dev origin under `--real`.
|
|
@@ -55,7 +58,7 @@ export async function runJob(root, name, scheduledAt, run = runCommand, real = f
|
|
|
55
58
|
}
|
|
56
59
|
if (result.code !== 0)
|
|
57
60
|
throw new CliError("JOB_FAILED", `Job ${name} failed: ${jobFailureReason(result.stderr)}`, undefined, "Fix what the error names in jobs/<name>.ts, then run this again; `details.stderr` carries the last lines.", undefined, { details: { stderr: result.stderr.trim().split("\n").slice(-20) } });
|
|
58
|
-
return { name, schedule: job.schedule, scheduledAt: timestamp, mode: real ? "real" : "local" };
|
|
61
|
+
return { name, ...(job.schedule ? { schedule: job.schedule } : {}), scheduledAt: timestamp, mode: real ? "real" : "local" };
|
|
59
62
|
}
|
|
60
63
|
/**
|
|
61
64
|
* The line of a failed job's stderr that says what went wrong. Node prints
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "isomorph.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.
|
|
3
|
+
"kitVersion": "0.10.0",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@isomorph.ai/app-sdk",
|
|
6
|
-
"version": "1.
|
|
7
|
-
"tarballSha256": "
|
|
8
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
6
|
+
"version": "1.3.0",
|
|
7
|
+
"tarballSha256": "8e134408de3113d63e704685be4ed841e20a3b4cc0b5ccfedaa997da1d86cc72",
|
|
8
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:8e134408de3113d63e704685be4ed841e20a3b4cc0b5ccfedaa997da1d86cc72"
|
|
9
9
|
},
|
|
10
10
|
"images": {
|
|
11
|
-
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
11
|
+
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:8ab5a1d63704761379c4cac74a8415875476aa3febbab39054e980398c071e03",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:f44a7e13b48053c4984fd499ae83251420987fab7eadc5737212a143b1b3f049"
|
|
13
13
|
},
|
|
14
14
|
"nativeRuntime": {
|
|
15
15
|
"darwinArm64": {
|
|
16
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
17
|
-
"sha256": "
|
|
16
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:7bc2a1df6b02105f49eda825430ca0719482c4a2a06b26c333493ed80f2cf1eb",
|
|
17
|
+
"sha256": "7bc2a1df6b02105f49eda825430ca0719482c4a2a06b26c333493ed80f2cf1eb"
|
|
18
18
|
},
|
|
19
19
|
"linuxX64": {
|
|
20
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
21
|
-
"sha256": "
|
|
20
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:3737565cdf396e7747e2e5dccc028b88cc05c1505ef369f517363570e266010c",
|
|
21
|
+
"sha256": "3737565cdf396e7747e2e5dccc028b88cc05c1505ef369f517363570e266010c"
|
|
22
22
|
},
|
|
23
23
|
"windowsX64": {
|
|
24
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
25
|
-
"sha256": "
|
|
24
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:e7232bf46d03c7bb7598276c0d0d45f65fd9633762dc8051efef339b5115c0b0",
|
|
25
|
+
"sha256": "e7232bf46d03c7bb7598276c0d0d45f65fd9633762dc8051efef339b5115c0b0"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"brief": {
|
|
@@ -89,7 +89,13 @@ export function nativeGatewayConfig(project, ports, stateDir) {
|
|
|
89
89
|
...config.bindings[0],
|
|
90
90
|
databaseUrl: `postgresql://${LOCAL.gatewayRole}:${LOCAL.dbPassword}@127.0.0.1:${ports.postgres}/${LOCAL.database}?sslmode=disable`,
|
|
91
91
|
publicBaseUrl: `http://127.0.0.1:${ports.origin}`,
|
|
92
|
-
localFilesDirectory: join(stateDir, "files")
|
|
92
|
+
localFilesDirectory: join(stateDir, "files"),
|
|
93
|
+
// The app's own server side (actions/, jobs/) is the kit runtime on its
|
|
94
|
+
// port; the gateway forwards /_harbour/actions/<name> there with the
|
|
95
|
+
// verified identity, as the hosted gateway does. An app with no server
|
|
96
|
+
// modules has no runtime and the route answers "unavailable".
|
|
97
|
+
capabilities: ["data", "files", "realtime", "telemetry", "actions"],
|
|
98
|
+
actionBaseUrl: `http://127.0.0.1:${ports.runtime}`
|
|
93
99
|
};
|
|
94
100
|
config.localSession = {
|
|
95
101
|
...config.localSession,
|
|
@@ -128,8 +134,8 @@ export async function freePort() {
|
|
|
128
134
|
});
|
|
129
135
|
}
|
|
130
136
|
export async function allocatePorts() {
|
|
131
|
-
const [postgres, minio, gateway, vite, origin] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort()]);
|
|
132
|
-
return { postgres: postgres, minio: minio, gateway: gateway, vite: vite, origin: origin };
|
|
137
|
+
const [postgres, minio, gateway, vite, origin, runtime] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort(), freePort()]);
|
|
138
|
+
return { postgres: postgres, minio: minio, gateway: gateway, vite: vite, origin: origin, runtime: runtime };
|
|
133
139
|
}
|
|
134
140
|
/** Acquires `.isomorph/local/dev.lock`; a lock whose process is gone is stale and replaced. */
|
|
135
141
|
export async function acquireDevLock(root, ports, isAlive = pidAlive) {
|
|
@@ -185,11 +191,82 @@ catch {
|
|
|
185
191
|
return false;
|
|
186
192
|
} }
|
|
187
193
|
// ---- Native lifecycle -----------------------------------------------------------------
|
|
194
|
+
/**
|
|
195
|
+
* The platform-owned runs table the kit runtime works (app-sdk runtime.ts):
|
|
196
|
+
* one row per enqueued run, readable and writable by the person who started
|
|
197
|
+
* it and by the workload identity the runtime calls the gateway as. Applied
|
|
198
|
+
* after the app's migrations by `isomorph dev`; the deployment pipeline
|
|
199
|
+
* appends the same text (byte for byte — tests/cli-job-runs.test.ts pins its
|
|
200
|
+
* digest) to the app's migrations as 9999_zzzz_isomorph_job_runs.sql.
|
|
201
|
+
*/
|
|
202
|
+
export const JOB_RUNS_MIGRATION = `CREATE TABLE IF NOT EXISTS public.isomorph_job_runs (
|
|
203
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
204
|
+
job text NOT NULL,
|
|
205
|
+
input jsonb,
|
|
206
|
+
status text NOT NULL DEFAULT 'queued',
|
|
207
|
+
attempt integer NOT NULL DEFAULT 0,
|
|
208
|
+
max_attempts integer NOT NULL DEFAULT 3,
|
|
209
|
+
progress integer NOT NULL DEFAULT 0,
|
|
210
|
+
note text,
|
|
211
|
+
result jsonb,
|
|
212
|
+
error text,
|
|
213
|
+
owner_subject text NOT NULL,
|
|
214
|
+
cancel_requested boolean NOT NULL DEFAULT false,
|
|
215
|
+
lease_until timestamptz,
|
|
216
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
217
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
218
|
+
);
|
|
219
|
+
CREATE INDEX IF NOT EXISTS isomorph_job_runs_queue ON public.isomorph_job_runs (status, created_at);
|
|
220
|
+
ALTER TABLE public.isomorph_job_runs ENABLE ROW LEVEL SECURITY;
|
|
221
|
+
DO $$ BEGIN
|
|
222
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'isomorph_job_runs' AND policyname = 'isomorph_job_runs_access') THEN
|
|
223
|
+
CREATE POLICY isomorph_job_runs_access ON public.isomorph_job_runs
|
|
224
|
+
USING (owner_subject = current_setting('harbour.user_id', true) OR current_setting('harbour.user_id', true) LIKE 'workload:%')
|
|
225
|
+
WITH CHECK (owner_subject = current_setting('harbour.user_id', true) OR current_setting('harbour.user_id', true) LIKE 'workload:%');
|
|
226
|
+
END IF;
|
|
227
|
+
END $$;
|
|
228
|
+
`;
|
|
229
|
+
/** The kit runtime inside the app's installed SDK (app-sdk runtime.ts). */
|
|
230
|
+
export const KIT_RUNTIME_SCRIPT = "node_modules/@isomorph.ai/app-sdk/dist/runtime.js";
|
|
231
|
+
/** Whether the app has server-side modules for the kit runtime to serve. */
|
|
232
|
+
export async function hasServerModules(root) {
|
|
233
|
+
for (const dir of ["actions", "jobs"]) {
|
|
234
|
+
const entries = await readdir(join(root, dir)).catch(() => []);
|
|
235
|
+
if (entries.some(name => name.endsWith(".ts")))
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* `.isomorph/local/secrets.env`: `NAME=value` lines (an optional `export `,
|
|
242
|
+
* `#` comments, single or double quotes around the value), the values a
|
|
243
|
+
* builder's server code reads as `process.env.NAME` on their machine. The
|
|
244
|
+
* file is under the git-ignored local directory; deployed, the same names are
|
|
245
|
+
* the secrets IT sets.
|
|
246
|
+
*/
|
|
247
|
+
export async function readLocalSecrets(root) {
|
|
248
|
+
const text = await readFile(join(kitPaths(root).local, "secrets.env"), "utf8").catch(() => "");
|
|
249
|
+
const values = {};
|
|
250
|
+
for (const raw of text.split("\n")) {
|
|
251
|
+
const line = raw.trim();
|
|
252
|
+
if (!line || line.startsWith("#"))
|
|
253
|
+
continue;
|
|
254
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
255
|
+
if (!match)
|
|
256
|
+
continue;
|
|
257
|
+
let value = match[2].trim();
|
|
258
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
259
|
+
value = value.slice(1, -1);
|
|
260
|
+
values[match[1]] = value;
|
|
261
|
+
}
|
|
262
|
+
return values;
|
|
263
|
+
}
|
|
188
264
|
export class LocalRuntime {
|
|
189
265
|
root;
|
|
190
266
|
run;
|
|
191
267
|
project;
|
|
192
268
|
postgres;
|
|
269
|
+
kitRuntime;
|
|
193
270
|
windowsPgCtl;
|
|
194
271
|
gateway;
|
|
195
272
|
gates = new Map();
|
|
@@ -203,6 +280,36 @@ export class LocalRuntime {
|
|
|
203
280
|
}
|
|
204
281
|
/** The App Gateway process this runtime started, for the dev lock. */
|
|
205
282
|
get gatewayPid() { return this.gateway?.pid; }
|
|
283
|
+
get runtimePid() { return this.kitRuntime?.pid; }
|
|
284
|
+
/**
|
|
285
|
+
* Starts the app's kit runtime (its actions and jobs) with node, the way
|
|
286
|
+
* the hosted pod and the gate run it: from the app root, the session's
|
|
287
|
+
* identities and workload token in its environment, the local secrets, and
|
|
288
|
+
* the gateway it talks to. Answers false when the installed SDK has no
|
|
289
|
+
* runtime (a kit older than 0.10.0): the app runs without server code.
|
|
290
|
+
*/
|
|
291
|
+
async startKitRuntime(env, gatewayUrl, output) {
|
|
292
|
+
if (!this.ports)
|
|
293
|
+
throw new CliError("LOCAL_RUNTIME_FAILED", "The local runtime was not prepared.");
|
|
294
|
+
const script = join(this.root, ...KIT_RUNTIME_SCRIPT.split("/"));
|
|
295
|
+
if (!(await exists(script))) {
|
|
296
|
+
output?.("The app's SDK has no kit runtime, so actions/ and jobs/ are not served; run `isomorph init --upgrade`.");
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const secrets = await readLocalSecrets(this.root);
|
|
300
|
+
this.kitRuntime = spawn(process.execPath, ["--experimental-strip-types", script], {
|
|
301
|
+
cwd: this.root,
|
|
302
|
+
env: { ...process.env, ...env, ...secrets, PORT: String(this.ports.runtime), ISOMORPH_GATEWAY_URL: gatewayUrl, ISOMORPH_APP_ROOT: this.root, NODE_NO_WARNINGS: "1" },
|
|
303
|
+
stdio: ["ignore", "inherit", "inherit"]
|
|
304
|
+
});
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
/** Applies the platform-owned runs table (JOB_RUNS_MIGRATION); idempotent. */
|
|
308
|
+
async installJobRuns() {
|
|
309
|
+
const result = await this.psql(JOB_RUNS_MIGRATION);
|
|
310
|
+
if (result.code !== 0)
|
|
311
|
+
throw new CliError("MIGRATION_FAILED", `The Isomorph runs table could not be created: ${result.stderr.trim().split("\n").at(-1) ?? "psql error"}`);
|
|
312
|
+
}
|
|
206
313
|
async writeFiles(bundle, ports) {
|
|
207
314
|
const paths = kitPaths(this.root);
|
|
208
315
|
await mkdir(paths.state, { recursive: true });
|
|
@@ -308,6 +415,7 @@ export class LocalRuntime {
|
|
|
308
415
|
* address pools have been fully subnetted").
|
|
309
416
|
*/
|
|
310
417
|
async down() {
|
|
418
|
+
this.kitRuntime?.kill("SIGTERM");
|
|
311
419
|
this.gateway?.kill("SIGTERM");
|
|
312
420
|
for (const child of this.gates.values())
|
|
313
421
|
child.kill("SIGTERM");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Isomorph development kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"harbour": {
|
|
39
39
|
"kitBundle": {
|
|
40
40
|
"repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
|
|
41
|
-
"version": "0.
|
|
41
|
+
"version": "0.10.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
}
|