anbaric-cloud-hosting 1.25.0 → 1.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
5
5
  "license": "MIT",
6
6
  "author": "chris@anbaric.ai",
@@ -18,18 +18,18 @@
18
18
  "@aws-sdk/client-s3": "^3.1110.0",
19
19
  "@aws-sdk/client-secrets-manager": "^3.700.0",
20
20
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
21
- "anbaric-data-store": "^1.25.0",
22
- "anbaric-plugins": "^1.25.0",
23
- "anbaric-tsapi": "^1.25.0",
24
- "anbaric-web": "^1.25.0",
21
+ "anbaric-data-store": "^1.27.0",
22
+ "anbaric-plugins": "^1.27.0",
23
+ "anbaric-tsapi": "^1.27.0",
24
+ "anbaric-web": "^1.27.0",
25
25
  "esbuild": "^0.28.2",
26
26
  "pg": "^8.16.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.2.0",
30
30
  "@types/pg": "^8.15.0",
31
- "anbaric-impl-cloud": "^1.25.0",
32
- "anbaric-state-machine": "^1.25.0",
31
+ "anbaric-impl-cloud": "^1.27.0",
32
+ "anbaric-state-machine": "^1.27.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -2,6 +2,7 @@ import {ChildProcess, spawn} from "node:child_process";
2
2
  import {mkdir, readFile, rm, symlink, writeFile} from "node:fs/promises";
3
3
  import {join} from "node:path";
4
4
  import {adminPing} from "../app-admin/adminPing";
5
+ import {DocGenerator} from "../docs/DocGenerator";
5
6
  import {BuildLayer, DeploymentStatus, DeploymentSummary} from "./BuildLayer";
6
7
 
7
8
  type Deployment = {
@@ -30,6 +31,7 @@ const adminProbe : Probe = (host, port) => adminPing(host, port, LIVENESS_PROBE_
30
31
  abstract class BaseBuildLayer implements BuildLayer {
31
32
 
32
33
  protected deployments = new Map<string, Deployment>();
34
+ protected docGenerator? : DocGenerator;
33
35
  private nextAppIndex = 0;
34
36
  private hydration? : Promise<void>;
35
37
 
@@ -127,6 +129,12 @@ abstract class BaseBuildLayer implements BuildLayer {
127
129
  this.log(deployment, "extracting application bundle");
128
130
  await this.run(deployment, "tar", ["-xzf", tarballPath, "-C", appDir]);
129
131
 
132
+ // Documentation is generated off the critical path: fire-and-forget from
133
+ // the extracted source, before the build, so a slow model call can never
134
+ // delay or fail the deploy. Regenerated on every deploy, replaces=redeploy.
135
+ void this.docGenerator?.generate(deployment.appName, appDir, { redeploy: !!deployment.replaces })
136
+ .catch(error => this.log(deployment, `doc generation skipped: ${error instanceof Error ? error.message : error}`));
137
+
130
138
  this.log(deployment, "linking anbaric workspace packages");
131
139
  const manifest = JSON.parse(await readFile(join(appDir, "package.json"), "utf8"));
132
140
  await this.linkWorkspacePackages(appDir, manifest);
@@ -155,9 +163,27 @@ abstract class BaseBuildLayer implements BuildLayer {
155
163
  await new Promise(resolve => setTimeout(resolve, LIVENESS_PROBE_INTERVAL_MS));
156
164
  }
157
165
 
166
+ // The admin port never answering almost always means the app process
167
+ // crashed on boot, so pull whatever it logged and put the real cause in
168
+ // the deploy result - not just the timeout, which says nothing.
169
+ const diagnostics = await this.diagnostics(deployment).catch(() => []);
170
+
158
171
  await this.stop(deployment);
159
172
  deployment.status = "failed";
160
173
  this.log(deployment, `app admin port ${deployment.adminPort} did not answer within ${this.livenessTimeoutMs / 1000}s`);
174
+ if (diagnostics.length > 0) {
175
+ this.log(deployment, "the app's last output before it stopped:");
176
+ for (const line of diagnostics) this.log(deployment, ` ${line}`);
177
+ } else {
178
+ this.log(deployment, "no output was captured - the app may have failed before logging, or is still starting");
179
+ }
180
+ }
181
+
182
+ // The app's recent output, for diagnosing a boot that never became live.
183
+ // Each build layer reads it from where its apps log (container logs,
184
+ // CloudWatch); the default has nothing to offer.
185
+ protected async diagnostics(_deployment : Deployment) : Promise<Array<string>> {
186
+ return [];
161
187
  }
162
188
 
163
189
  private async linkWorkspacePackages(appDir : string, manifest : { dependencies? : Record<string, string> }) : Promise<void> {
@@ -2,6 +2,7 @@ import {spawn} from "node:child_process";
2
2
  import {writeFile} from "node:fs/promises";
3
3
  import {join} from "node:path";
4
4
  import {BaseBuildLayer, Deployment, Probe} from "./BaseBuildLayer";
5
+ import {DocGenerator} from "../docs/DocGenerator";
5
6
  import {childLines} from "./childLines";
6
7
 
7
8
  type LogStreamer = (container : string, signal : AbortSignal) => AsyncIterable<string>;
@@ -15,6 +16,7 @@ type DockerBuildLayerOptions = {
15
16
  platformUrl : string,
16
17
  sqlDatabaseUrl? : string,
17
18
  sqlSchema? : string,
19
+ docGenerator? : DocGenerator,
18
20
  };
19
21
 
20
22
  type CommandRunner = (command : string, args : Array<string>, onOutput : (line : string) => void) => Promise<void>;
@@ -51,6 +53,21 @@ class DockerBuildLayer extends BaseBuildLayer {
51
53
  private runCommand : CommandRunner = spawnRunner, probe? : Probe,
52
54
  private logStreamer : LogStreamer = dockerLogStreamer) {
53
55
  super(appsDir, consumerPortBase, probe);
56
+ this.docGenerator = options.docGenerator;
57
+ }
58
+
59
+ protected async diagnostics(deployment : Deployment) : Promise<Array<string>> {
60
+ return new Promise((resolve) => {
61
+ const lines : Array<string> = [];
62
+ const take = (buffer : Buffer) => {
63
+ for (const line of buffer.toString().split("\n")) if (line.trim()) lines.push(line);
64
+ };
65
+ const logs = spawn("docker", ["logs", "--tail", "30", this.appHostFor(deployment.appName)]);
66
+ logs.stdout.on("data", take);
67
+ logs.stderr.on("data", take);
68
+ logs.on("close", () => resolve(lines.slice(-30)));
69
+ logs.on("error", () => resolve([]));
70
+ });
54
71
  }
55
72
 
56
73
  protected appHostFor(appName : string) : string {
@@ -90,6 +107,7 @@ class DockerBuildLayer extends BaseBuildLayer {
90
107
  "--env", "ANBARIC_SECRET_STORE_TYPE=cloud",
91
108
  "--env", "ANBARIC_AUDITOR_TYPE=cloud",
92
109
  "--env", "ANBARIC_SESSION_RESOLVER_TYPE=cloud",
110
+ "--env", "ANBARIC_NOTIFIER_TYPE=cloud",
93
111
  "--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
94
112
  "--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
95
113
  ...sqlEnv,
@@ -7,8 +7,9 @@ import {ECSClient, CreateServiceCommand, DeleteServiceCommand, DescribeServicesC
7
7
  import {S3Client, PutObjectCommand} from "@aws-sdk/client-s3";
8
8
  import {ServiceDiscoveryClient, CreateServiceCommand as CreateDiscoveryServiceCommand,
9
9
  ListServicesCommand} from "@aws-sdk/client-servicediscovery";
10
- import {CloudWatchLogsClient, StartLiveTailCommand} from "@aws-sdk/client-cloudwatch-logs";
10
+ import {CloudWatchLogsClient, FilterLogEventsCommand, StartLiveTailCommand} from "@aws-sdk/client-cloudwatch-logs";
11
11
  import {BaseBuildLayer, Deployment, Probe} from "./BaseBuildLayer";
12
+ import {DocGenerator} from "../docs/DocGenerator";
12
13
  import {dockerfileFor} from "./DockerBuildLayer";
13
14
 
14
15
  type FargateBuildLayerOptions = {
@@ -30,6 +31,7 @@ type FargateBuildLayerOptions = {
30
31
  servicesApiKey? : string,
31
32
  sqlDatabaseUrlSecretArn? : string,
32
33
  sqlSchema? : string,
34
+ docGenerator? : DocGenerator,
33
35
  };
34
36
 
35
37
  type AwsClients = {
@@ -61,6 +63,20 @@ class FargateBuildLayer extends BaseBuildLayer {
61
63
  private buildPollIntervalMs : number = 5000) {
62
64
  super(appsDir, consumerPortBase, probe, FARGATE_LIVENESS_TIMEOUT_MS);
63
65
  this.aws = aws ?? defaultClients(options.awsRegion);
66
+ this.docGenerator = options.docGenerator;
67
+ }
68
+
69
+ protected async diagnostics(deployment : Deployment) : Promise<Array<string>> {
70
+ const response = await this.aws.cloudWatchLogs.send(new FilterLogEventsCommand({
71
+ logGroupName: this.options.appsLogGroup,
72
+ logStreamNamePrefix: deployment.appName,
73
+ startTime: Date.now() - 10 * 60_000,
74
+ limit: 40,
75
+ }));
76
+ return (response.events ?? [])
77
+ .map((event : any) => (event.message ?? "").trimEnd())
78
+ .filter((line : string) => line.length > 0)
79
+ .slice(-30);
64
80
  }
65
81
 
66
82
  protected appHostFor(appName : string) : string {
@@ -179,6 +195,7 @@ class FargateBuildLayer extends BaseBuildLayer {
179
195
  { name: "ANBARIC_SECRET_STORE_TYPE", value: "cloud" },
180
196
  { name: "ANBARIC_AUDITOR_TYPE", value: "cloud" },
181
197
  { name: "ANBARIC_SESSION_RESOLVER_TYPE", value: "cloud" },
198
+ { name: "ANBARIC_NOTIFIER_TYPE", value: "cloud" },
182
199
  { name: "ANBARIC_CONSUMER_PORT", value: String(deployment.consumerPort) },
183
200
  { name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
184
201
  ...(this.options.servicesUrl ? [{ name: "ANBARIC_SERVICES_URL", value: this.options.servicesUrl }] : []),
@@ -0,0 +1,19 @@
1
+ type AppDoc = {
2
+ slug : string,
3
+ title : string,
4
+ markdown : string,
5
+ };
6
+
7
+ /* Generated user documentation for an app. Docs are regenerated wholesale on
8
+ every deploy, so the store replaces an app's whole set at once rather than
9
+ upserting doc by doc - a doc that no longer applies after a redeploy must not
10
+ linger. */
11
+ interface AppDocsStore {
12
+
13
+ replaceForApp(appId : string, docs : Array<AppDoc>) : Promise<void>;
14
+
15
+ listForApp(appId : string) : Promise<Array<AppDoc>>;
16
+
17
+ }
18
+
19
+ export type { AppDoc, AppDocsStore };
@@ -0,0 +1,17 @@
1
+ import {AppDoc, AppDocsStore} from "./AppDocsStore";
2
+
3
+ class InMemoryAppDocsStore implements AppDocsStore {
4
+
5
+ private byApp = new Map<string, Array<AppDoc>>();
6
+
7
+ async replaceForApp(appId : string, docs : Array<AppDoc>) : Promise<void> {
8
+ this.byApp.set(appId, docs.map(doc => ({ ...doc })));
9
+ }
10
+
11
+ async listForApp(appId : string) : Promise<Array<AppDoc>> {
12
+ return (this.byApp.get(appId) ?? []).map(doc => ({ ...doc }));
13
+ }
14
+
15
+ }
16
+
17
+ export { InMemoryAppDocsStore }
@@ -0,0 +1,43 @@
1
+ import {Pool} from "pg";
2
+ import {AppDoc, AppDocsStore} from "./AppDocsStore";
3
+
4
+ /* Replacing an app's docs is a delete-then-insert in one transaction, so a
5
+ redeploy swaps the whole set atomically - a reader never sees a mix of old
6
+ and new, and a crash mid-write leaves the previous docs intact. */
7
+ class PostgresAppDocsStore implements AppDocsStore {
8
+
9
+ constructor(private pool : Pool) {}
10
+
11
+ async replaceForApp(appId : string, docs : Array<AppDoc>) : Promise<void> {
12
+ const client = await this.pool.connect();
13
+
14
+ try {
15
+ await client.query("BEGIN");
16
+ await client.query("DELETE FROM app_docs WHERE app_id = $1", [appId]);
17
+
18
+ if (docs.length > 0) {
19
+ await client.query(
20
+ `INSERT INTO app_docs (app_id, slug, title, markdown)
21
+ SELECT $1, * FROM unnest($2::text[], $3::text[], $4::text[])`,
22
+ [appId, docs.map(doc => doc.slug), docs.map(doc => doc.title), docs.map(doc => doc.markdown)],
23
+ );
24
+ }
25
+
26
+ await client.query("COMMIT");
27
+ } catch (error) {
28
+ await client.query("ROLLBACK").catch(() => {});
29
+ throw error;
30
+ } finally {
31
+ client.release();
32
+ }
33
+ }
34
+
35
+ async listForApp(appId : string) : Promise<Array<AppDoc>> {
36
+ const result = await this.pool.query(
37
+ "SELECT slug, title, markdown FROM app_docs WHERE app_id = $1 ORDER BY slug", [appId]);
38
+ return result.rows.map(row => ({ slug: row.slug, title: row.title, markdown: row.markdown }));
39
+ }
40
+
41
+ }
42
+
43
+ export { PostgresAppDocsStore }
@@ -94,6 +94,18 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
94
94
  ON job_run_schedule (app_id, workflow_id, run_at)`);
95
95
  await pool.query(`CREATE INDEX IF NOT EXISTS job_run_schedule_due
96
96
  ON job_run_schedule (run_at) WHERE claimed_at IS NULL`);
97
+ // User documentation the platform generates from an app's source on deploy,
98
+ // keyed by (app_id, slug). A redeploy replaces the whole set for an app.
99
+ await pool.query(`
100
+ CREATE TABLE IF NOT EXISTS app_docs (
101
+ app_id TEXT NOT NULL,
102
+ slug TEXT NOT NULL,
103
+ title TEXT NOT NULL DEFAULT '',
104
+ markdown TEXT NOT NULL,
105
+ generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
106
+ PRIMARY KEY (app_id, slug)
107
+ )
108
+ `);
97
109
  await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
98
110
  await pool.query(`
99
111
  CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
@@ -0,0 +1,31 @@
1
+ import {AppDocsStore} from "../data-store/AppDocsStore";
2
+ import {DocModel} from "./DocModel";
3
+ import {gatherSources} from "./gatherSources";
4
+
5
+ /* Generates an app's user documentation from its source and stores it. It runs
6
+ off the deploy's critical path (the build layer calls it fire-and-forget), so
7
+ it is deliberately all-or-nothing and never throws: any failure - no model
8
+ configured, a slow or erroring model, unreadable source - is logged and the
9
+ existing docs are left as they are. */
10
+ class DocGenerator {
11
+
12
+ constructor(private model : DocModel, private store : AppDocsStore) {}
13
+
14
+ async generate(appName : string, appDir : string, options : { redeploy : boolean }) : Promise<void> {
15
+ try {
16
+ const { files, truncated } = await gatherSources(appDir);
17
+ if (files.length === 0) return;
18
+
19
+ const docs = await this.model.writeDocs({ appName, files, truncated });
20
+ if (docs.length === 0) return;
21
+
22
+ await this.store.replaceForApp(appName, docs);
23
+ console.log(`[docs] ${options.redeploy ? "regenerated" : "generated"} ${docs.length} doc(s) for "${appName}"`);
24
+ } catch (error) {
25
+ console.error(`[docs] could not generate documentation for "${appName}": ${error instanceof Error ? error.message : error}`);
26
+ }
27
+ }
28
+
29
+ }
30
+
31
+ export { DocGenerator }
@@ -0,0 +1,18 @@
1
+ import {AppDoc} from "../data-store/AppDocsStore";
2
+
3
+ type DocContext = {
4
+ appName : string,
5
+ files : Array<{ path : string, content : string }>,
6
+ truncated : boolean,
7
+ };
8
+
9
+ /* Turns an app's source into a set of user-facing docs. An implementation that
10
+ cannot reach a model (nothing configured) returns an empty set rather than
11
+ throwing, so doc generation quietly does nothing. */
12
+ interface DocModel {
13
+
14
+ writeDocs(context : DocContext) : Promise<Array<AppDoc>>;
15
+
16
+ }
17
+
18
+ export type { DocContext, DocModel };
@@ -0,0 +1,82 @@
1
+ import {AppDoc} from "../data-store/AppDocsStore";
2
+ import {DocContext, DocModel} from "./DocModel";
3
+
4
+ type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
5
+
6
+ const SYSTEM_PROMPT = `You write user documentation for an application deployed on Anbaric.
7
+
8
+ Anbaric apps model their domain as state machines: long-lived jobs move through named states, driven by actions and transitions, with data validated against a schema and every change attributed to an actor. Read the whole source you are given and write documentation for the people who will USE this app - what it does, the workflows it runs, how someone drives it, and anything they need to know. Write clear markdown. Prefer several focused docs over one long one; give each a short kebab-case slug and a human title. Do not invent features the source does not show. If the source was truncated, document only what you can see.`;
9
+
10
+ const OUTPUT_SCHEMA = {
11
+ type: "object",
12
+ additionalProperties: false,
13
+ properties: {
14
+ docs: {
15
+ type: "array",
16
+ items: {
17
+ type: "object",
18
+ additionalProperties: false,
19
+ properties: {
20
+ slug: { type: "string" },
21
+ title: { type: "string" },
22
+ markdown: { type: "string" },
23
+ },
24
+ required: ["slug", "title", "markdown"],
25
+ },
26
+ },
27
+ },
28
+ required: ["docs"],
29
+ };
30
+
31
+ /* The platform's doc writer over an OpenAI-compatible chat endpoint - the same
32
+ gateway the agentic actions use (ANBARIC_AI_GATEWAY_*), so no new config.
33
+ With no token there is nothing to call, so it returns no docs and the
34
+ generator does nothing. */
35
+ class GatewayDocModel implements DocModel {
36
+
37
+ constructor(private baseUrl : string = process.env.ANBARIC_AI_GATEWAY_URL ?? "https://api.openai.com/v1",
38
+ private apiKey : string = process.env.ANBARIC_AI_GATEWAY_TOKEN ?? "",
39
+ private model : string = process.env.ANBARIC_AGENTIC_MODEL ?? "gpt-5.4-mini",
40
+ private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
41
+
42
+ async writeDocs(context : DocContext) : Promise<Array<AppDoc>> {
43
+ if (!this.apiKey) {
44
+ console.warn(`[docs] no ANBARIC_AI_GATEWAY_TOKEN - skipping documentation for "${context.appName}"`);
45
+ return [];
46
+ }
47
+
48
+ const response = await this.fetchFn(`${this.baseUrl.replace(/\/$/, "")}/chat/completions`, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json", "authorization": `Bearer ${this.apiKey}` },
51
+ body: JSON.stringify({
52
+ model: this.model,
53
+ messages: [
54
+ { role: "system", content: SYSTEM_PROMPT },
55
+ { role: "user", content: this.render(context) },
56
+ ],
57
+ response_format: {
58
+ type: "json_schema",
59
+ json_schema: { name: "docs", strict: true, schema: OUTPUT_SCHEMA },
60
+ },
61
+ }),
62
+ });
63
+
64
+ if (!response.ok) {
65
+ const problem = await response.json().catch(() => ({}));
66
+ throw new Error(`Doc model request failed with status ${response.status}${problem.error?.message ? `: ${problem.error.message}` : ""}`);
67
+ }
68
+
69
+ const message = (await response.json()).choices?.[0]?.message?.content;
70
+ const docs = message ? JSON.parse(message).docs : [];
71
+ return Array.isArray(docs) ? docs : [];
72
+ }
73
+
74
+ private render(context : DocContext) : string {
75
+ const header = `App "${context.appName}"${context.truncated ? " (source was truncated - some files are omitted)" : ""}. Source:`;
76
+ const files = context.files.map(file => `\n\n=== ${file.path} ===\n${file.content}`).join("");
77
+ return header + files;
78
+ }
79
+
80
+ }
81
+
82
+ export { GatewayDocModel }
@@ -0,0 +1,79 @@
1
+ import {readFile, readdir} from "node:fs/promises";
2
+ import {join, relative} from "node:path";
3
+
4
+ const INCLUDE = /\.(ts|tsx|js|jsx|json|md|css|html)$/;
5
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".next", "build", "coverage"]);
6
+ const SKIP_FILES = new Set(["package-lock.json"]);
7
+
8
+ // A file this big that isn't a manifest is almost always baked data or an
9
+ // inlined asset - noise that would crowd out code and burn tokens.
10
+ const MAX_FILE_BYTES = 30_000;
11
+ // ~60K tokens of source, a generous ceiling; real apps are far under it.
12
+ const MAX_TOTAL_BYTES = 240_000;
13
+
14
+ type GatheredFile = { path : string, content : string };
15
+ type Gathered = { files : Array<GatheredFile>, truncated : boolean };
16
+
17
+ const isManifest = (path : string) : boolean =>
18
+ /(^|\/)package\.json$/.test(path) || /(^|\/)\.anbaric\/app-config\.json$/.test(path) || /(^|\/)readme\.md$/i.test(path);
19
+
20
+ // Manifests and docs first, then source, then the rest - so a truncated
21
+ // snapshot keeps the files that describe the app best.
22
+ const priority = (path : string) : number => {
23
+ if (isManifest(path)) return 0;
24
+ if (path.toLowerCase().endsWith(".md")) return 1;
25
+ if (/\.(ts|tsx|js|jsx)$/.test(path)) return 2;
26
+ return 3;
27
+ };
28
+
29
+ const walk = async (dir : string, root : string, found : Array<string>) : Promise<void> => {
30
+ let entries;
31
+ try {
32
+ entries = await readdir(dir, { withFileTypes: true });
33
+ } catch {
34
+ return;
35
+ }
36
+
37
+ for (const entry of entries) {
38
+ const full = join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ if (!SKIP_DIRS.has(entry.name)) await walk(full, root, found);
41
+ } else if (INCLUDE.test(entry.name) && !SKIP_FILES.has(entry.name)) {
42
+ found.push(relative(root, full));
43
+ }
44
+ }
45
+ };
46
+
47
+ /* The whole of an app's source, small enough to hand to a model in one go:
48
+ manifests, docs and code, skipping dependencies, build output and oversized
49
+ data files, and capped in total. `truncated` is true when the cap dropped
50
+ files, so the caller can tell the model not to assume it saw everything. */
51
+ const gatherSources = async (appDir : string) : Promise<Gathered> => {
52
+ const paths : Array<string> = [];
53
+ await walk(appDir, appDir, paths);
54
+ paths.sort((left, right) => priority(left) - priority(right) || left.localeCompare(right));
55
+
56
+ const files : Array<GatheredFile> = [];
57
+ let total = 0;
58
+ let truncated = false;
59
+
60
+ for (const path of paths) {
61
+ let content : string;
62
+ try {
63
+ content = await readFile(join(appDir, path), "utf8");
64
+ } catch {
65
+ continue;
66
+ }
67
+
68
+ if (!isManifest(path) && content.length > MAX_FILE_BYTES) continue;
69
+ if (total + content.length > MAX_TOTAL_BYTES) { truncated = true; continue; }
70
+
71
+ files.push({ path, content });
72
+ total += content.length;
73
+ }
74
+
75
+ return { files, truncated };
76
+ };
77
+
78
+ export { gatherSources };
79
+ export type { Gathered, GatheredFile };
@@ -1,4 +1,4 @@
1
- import {JobPersistence, JobRunSchedulePersistence, JsonStore, SecretStore} from "anbaric-tsapi";
1
+ import {JobPersistence, JobRunSchedulePersistence, JsonStore, Notifier, SecretStore} from "anbaric-tsapi";
2
2
  import {BuildLayer} from "../app-management/BuildLayer";
3
3
  import {AuditRecordStore} from "../auditing/AuditRecordStore";
4
4
  import {Authenticator} from "../auth/Authenticator";
@@ -19,6 +19,7 @@ import {DocumentsHandler} from "./handlers/DocumentsHandler";
19
19
  import {JobsHandler} from "./handlers/JobsHandler";
20
20
  import {FaviconHandler} from "./handlers/FaviconHandler";
21
21
  import {JobRunSchedulesHandler} from "./handlers/JobRunSchedulesHandler";
22
+ import {NotificationsHandler} from "./handlers/NotificationsHandler";
22
23
  import {LogoutHandler} from "./handlers/LogoutHandler";
23
24
  import {PagesHandler} from "./handlers/PagesHandler";
24
25
  import {PingHandler} from "./handlers/PingHandler";
@@ -56,7 +57,8 @@ class HostingServer {
56
57
  tenant? : string,
57
58
  auditRecords? : AuditRecordStore,
58
59
  plugins : Array<LoadedPlugin> = [],
59
- jobRunSchedules? : JobRunSchedulePersistence) {
60
+ jobRunSchedules? : JobRunSchedulePersistence,
61
+ notifier? : Notifier) {
60
62
  const pages = new PagesHandler();
61
63
  const ping = new PingHandler(tenant);
62
64
  const jobs = new JobsHandler(persistence);
@@ -84,6 +86,7 @@ class HostingServer {
84
86
  if (secrets) publicRouter.registerApi("secrets", secrets);
85
87
  if (audits) publicRouter.registerApi("audits", audits);
86
88
  if (jobRunSchedules) publicRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
89
+ if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
87
90
  if (cliAuthorizer) {
88
91
  publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
89
92
  publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
@@ -107,6 +110,7 @@ class HostingServer {
107
110
  if (secrets) internalRouter.registerApi("secrets", secrets);
108
111
  if (audits) internalRouter.registerApi("audits", audits);
109
112
  if (jobRunSchedules) internalRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
113
+ if (notifier) internalRouter.registerApi("notifications", new NotificationsHandler(notifier));
110
114
 
111
115
  // The favicon is requested by the browser before anyone has signed in,
112
116
  // and by deployed apps' pages, so gating it behind a session would send
@@ -0,0 +1,26 @@
1
+ import {Notifier, deserializeJob, deserializeWaitForInput} from "anbaric-tsapi";
2
+ import {Request} from "../Request";
3
+ import {RequestHandler} from "../RequestHandler";
4
+
5
+ /* Receives a deployed app's notification request and hands it to the platform's
6
+ server-side notifier. Delivery is the notifier's business - the platform just
7
+ reconstructs the job and its wait from the wire and passes them on. */
8
+ class NotificationsHandler implements RequestHandler {
9
+
10
+ constructor(private notifier : Notifier) {}
11
+
12
+ async handle(request : Request) : Promise<void> {
13
+ if (request.method !== "POST" || request.id) return request.notFound();
14
+
15
+ const { targets, job, waitingFor } = await request.body();
16
+ if (!Array.isArray(targets) || !job || !waitingFor) {
17
+ return request.reply(400, { error: "Expected { targets, job, waitingFor }" });
18
+ }
19
+
20
+ await this.notifier.notify(targets, deserializeJob(job), deserializeWaitForInput(waitingFor));
21
+ request.reply(204);
22
+ }
23
+
24
+ }
25
+
26
+ export { NotificationsHandler }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./auth/CliKeyStore";
9
9
  export * from "./auth/HttpCliKeyStore";
10
10
  export * from "./auth/InMemoryCliKeyStore";
11
11
  export * from "./auth/AuthenticatorLoader";
12
+ export * from "./notifications/NotifierLoader";
12
13
  export * from "./auth/KeyPair";
13
14
  export * from "./auth/Role";
14
15
  export * from "./auth/SessionSigner";
@@ -23,6 +24,12 @@ export * from "./app-admin/adminPing";
23
24
  export * from "./data-store/PostgresCliKeyStore";
24
25
  export * from "./data-store/PostgresJobPersistence";
25
26
  export * from "./data-store/PostgresJobRunSchedulePersistence";
27
+ export * from "./data-store/AppDocsStore";
28
+ export * from "./data-store/PostgresAppDocsStore";
29
+ export * from "./data-store/InMemoryAppDocsStore";
30
+ export * from "./docs/DocModel";
31
+ export * from "./docs/GatewayDocModel";
32
+ export * from "./docs/DocGenerator";
26
33
  export * from "./data-store/PostgresJsonStore";
27
34
  export * from "./data-store/Schema";
28
35
  export * from "./data-store/SecretsManagerSecretStore";
@@ -49,6 +56,7 @@ export * from "./plugins/PluginLoader";
49
56
  export * from "./plugins/PageDirectory";
50
57
  export * from "./hosting/handlers/QueueHandler";
51
58
  export * from "./hosting/handlers/JobRunSchedulesHandler";
59
+ export * from "./hosting/handlers/NotificationsHandler";
52
60
  export * from "./hosting/handlers/SecretsHandler";
53
61
  export * from "./hosting/handlers/StateMachinesHandler";
54
62
  export * from "./hosting/handlers/auth/AuthorizeCliHandler";
package/src/main.ts CHANGED
@@ -3,6 +3,7 @@ import {SecretStore} from "anbaric-tsapi";
3
3
  import {InMemorySecretStore} from "anbaric-data-store";
4
4
  import {Pool} from "pg";
5
5
  import {loadAuthenticator} from "./auth/AuthenticatorLoader";
6
+ import {loadNotifier} from "./notifications/NotifierLoader";
6
7
  import {CliAuthorizer} from "./auth/CliAuthorizer";
7
8
  import {HttpCliKeyStore} from "./auth/HttpCliKeyStore";
8
9
  import {TokenAuthenticator} from "./auth/TokenAuthenticator";
@@ -16,6 +17,9 @@ import {PostgresJsonStore} from "./data-store/PostgresJsonStore";
16
17
  import {PostgresQueue} from "./queuing/PostgresQueue";
17
18
  import {DockerBuildLayer} from "./app-management/DockerBuildLayer";
18
19
  import {FargateBuildLayer} from "./app-management/FargateBuildLayer";
20
+ import {DocGenerator} from "./docs/DocGenerator";
21
+ import {GatewayDocModel} from "./docs/GatewayDocModel";
22
+ import {PostgresAppDocsStore} from "./data-store/PostgresAppDocsStore";
19
23
  import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
20
24
  import {Dispatcher} from "./queuing/Dispatcher";
21
25
  import {HostingServer} from "./hosting/HostingServer";
@@ -31,6 +35,10 @@ const hostingPort = Number(process.env.ANBARIC_HOSTING_PORT ?? 8787);
31
35
  const internalPort = Number(process.env.ANBARIC_INTERNAL_PORT ?? 8788);
32
36
  const appsDir = process.env.ANBARIC_APPS_DIR ?? "/tmp/anbaric-apps";
33
37
 
38
+ // Generates user docs from an app's source on deploy; no-ops without an AI
39
+ // gateway token. The build layer calls it fire-and-forget off the deploy path.
40
+ const docGenerator = new DocGenerator(new GatewayDocModel(), new PostgresAppDocsStore(pool));
41
+
34
42
  const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
35
43
  ? new DockerBuildLayer(appsDir, {
36
44
  baseImage: process.env.ANBARIC_APP_BASE_IMAGE ?? "anbaric-v2-platform:local",
@@ -38,6 +46,7 @@ const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
38
46
  platformUrl: process.env.ANBARIC_PLATFORM_INTERNAL_URL ?? `http://localhost:${internalPort}`,
39
47
  sqlDatabaseUrl: process.env.ANBARIC_APP_SQL_DATABASE_URL,
40
48
  sqlSchema: process.env.ANBARIC_SQL_SCHEMA,
49
+ docGenerator,
41
50
  })
42
51
  : process.env.ANBARIC_BUILD_LAYER === "fargate"
43
52
  ? new FargateBuildLayer(appsDir, {
@@ -59,6 +68,7 @@ const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
59
68
  servicesApiKey: process.env.ANBARIC_SERVICES_API_KEY,
60
69
  sqlDatabaseUrlSecretArn: process.env.ANBARIC_AWS_APP_SQL_URL_SECRET,
61
70
  sqlSchema: process.env.ANBARIC_SQL_SCHEMA,
71
+ docGenerator,
62
72
  })
63
73
  : undefined;
64
74
 
@@ -84,7 +94,7 @@ const plugins = await new PluginLoader().load(process.env.ANBARIC_PLUGINS ?? "an
84
94
  const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
85
95
  (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
86
96
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
87
- new PostgresJobRunSchedulePersistence(pool));
97
+ new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE));
88
98
  const port = await server.listen(hostingPort);
89
99
  const internal = await server.listenInternal(internalPort);
90
100
 
@@ -0,0 +1,18 @@
1
+ import {Notifier} from "anbaric-tsapi";
2
+
3
+ /* Loads the server-side notifier by module name, the same way the authenticator
4
+ is loaded (see AuthenticatorLoader). The platform is open source and cannot
5
+ name a proprietary delivery mechanism, so a host names its module through
6
+ ANBARIC_NOTIFIER_MODULE and that module exports a createNotifier factory.
7
+ With nothing named, the platform accepts notifications but delivers nothing. */
8
+ const loadNotifier = async (moduleName? : string) : Promise<Notifier | undefined> => {
9
+ if (!moduleName) return undefined;
10
+
11
+ const module = await import(moduleName);
12
+ if (typeof module.createNotifier !== "function") {
13
+ throw new Error(`Notifier module "${moduleName}" does not export a createNotifier function`);
14
+ }
15
+ return module.createNotifier();
16
+ };
17
+
18
+ export { loadNotifier }