anbaric-cloud-hosting 1.26.0 → 1.28.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.26.0",
3
+ "version": "1.28.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.26.0",
22
- "anbaric-plugins": "^1.26.0",
23
- "anbaric-tsapi": "^1.26.0",
24
- "anbaric-web": "^1.26.0",
21
+ "anbaric-data-store": "^1.28.0",
22
+ "anbaric-plugins": "^1.28.0",
23
+ "anbaric-tsapi": "^1.28.0",
24
+ "anbaric-web": "^1.28.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.26.0",
32
- "anbaric-state-machine": "^1.26.0",
31
+ "anbaric-impl-cloud": "^1.28.0",
32
+ "anbaric-state-machine": "^1.28.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);
@@ -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,7 @@ 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;
54
57
  }
55
58
 
56
59
  protected async diagnostics(deployment : Deployment) : Promise<Array<string>> {
@@ -9,6 +9,7 @@ import {ServiceDiscoveryClient, CreateServiceCommand as CreateDiscoveryServiceCo
9
9
  ListServicesCommand} from "@aws-sdk/client-servicediscovery";
10
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,7 @@ 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;
64
67
  }
65
68
 
66
69
  protected async diagnostics(deployment : Deployment) : Promise<Array<string>> {
@@ -0,0 +1,23 @@
1
+ type AppDoc = {
2
+ slug : string,
3
+ title : string,
4
+ markdown : string,
5
+ parentSlug : string | null,
6
+ position : number,
7
+ };
8
+
9
+ /* Generated user documentation for an app, held as a shallow tree: root docs
10
+ (an "Introduction" and, verbatim, any README) have a null parent, and every
11
+ other doc names its parent's slug, ordered among its siblings by position.
12
+ Docs are regenerated wholesale on every deploy, so the store replaces an
13
+ app's whole set at once rather than upserting doc by doc - a doc that no
14
+ longer applies after a redeploy must not linger. */
15
+ interface AppDocsStore {
16
+
17
+ replaceForApp(appId : string, docs : Array<AppDoc>) : Promise<void>;
18
+
19
+ listForApp(appId : string) : Promise<Array<AppDoc>>;
20
+
21
+ }
22
+
23
+ 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,56 @@
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, parent_slug, position)
21
+ SELECT $1, * FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::int[])`,
22
+ [appId,
23
+ docs.map(doc => doc.slug),
24
+ docs.map(doc => doc.title),
25
+ docs.map(doc => doc.markdown),
26
+ docs.map(doc => doc.parentSlug),
27
+ docs.map(doc => doc.position)],
28
+ );
29
+ }
30
+
31
+ await client.query("COMMIT");
32
+ } catch (error) {
33
+ await client.query("ROLLBACK").catch(() => {});
34
+ throw error;
35
+ } finally {
36
+ client.release();
37
+ }
38
+ }
39
+
40
+ async listForApp(appId : string) : Promise<Array<AppDoc>> {
41
+ const result = await this.pool.query(
42
+ `SELECT slug, title, markdown, parent_slug, position
43
+ FROM app_docs WHERE app_id = $1
44
+ ORDER BY position, slug`, [appId]);
45
+ return result.rows.map(row => ({
46
+ slug: row.slug,
47
+ title: row.title,
48
+ markdown: row.markdown,
49
+ parentSlug: row.parent_slug,
50
+ position: row.position,
51
+ }));
52
+ }
53
+
54
+ }
55
+
56
+ export { PostgresAppDocsStore }
@@ -94,6 +94,24 @@ 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) and held as a shallow tree: a root doc has a null
99
+ // parent_slug, a child names its parent's slug, and position orders
100
+ // siblings. A redeploy replaces the whole set for an app.
101
+ await pool.query(`
102
+ CREATE TABLE IF NOT EXISTS app_docs (
103
+ app_id TEXT NOT NULL,
104
+ slug TEXT NOT NULL,
105
+ title TEXT NOT NULL DEFAULT '',
106
+ markdown TEXT NOT NULL,
107
+ parent_slug TEXT,
108
+ position INTEGER NOT NULL DEFAULT 0,
109
+ generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
110
+ PRIMARY KEY (app_id, slug)
111
+ )
112
+ `);
113
+ await pool.query("ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS parent_slug TEXT");
114
+ await pool.query("ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0");
97
115
  await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
98
116
  await pool.query(`
99
117
  CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
@@ -0,0 +1,97 @@
1
+ import {AppDoc, AppDocsStore} from "../data-store/AppDocsStore";
2
+ import {DocModel, DraftDoc} from "./DocModel";
3
+ import {gatherSources} from "./gatherSources";
4
+
5
+ const README = /^readme(\.md|\.markdown)?$/i;
6
+
7
+ /* Arranges the model's draft docs into the shallow tree the store holds: one
8
+ "introduction" root with every other page beneath it, plus the app's README
9
+ copied in verbatim as a second root beside the introduction. It is defensive
10
+ about what the model returned - a missing introduction, a dangling or looping
11
+ parent - so the stored set is always a well-formed single-rooted tree (two
12
+ roots once the README is added). */
13
+ const buildDocTree = (drafts : Array<DraftDoc>, readme : string | undefined) : Array<AppDoc> => {
14
+ const cleaned = drafts
15
+ .filter(draft => draft && typeof draft.slug === "string" && draft.slug.trim() && typeof draft.markdown === "string")
16
+ .map(draft => ({
17
+ slug: draft.slug.trim(),
18
+ title: (typeof draft.title === "string" && draft.title.trim() ? draft.title : draft.slug).trim(),
19
+ markdown: draft.markdown,
20
+ parentSlug: typeof draft.parentSlug === "string" && draft.parentSlug.trim() ? draft.parentSlug.trim() : null,
21
+ }))
22
+ .filter(draft => draft.slug !== "readme");
23
+
24
+ if (cleaned.length === 0) return [];
25
+
26
+ const bySlug = new Map(cleaned.map(draft => [draft.slug, draft]));
27
+
28
+ const intro = bySlug.get("introduction") ?? cleaned.find(draft => draft.parentSlug === null) ?? cleaned[0];
29
+ intro.parentSlug = null;
30
+
31
+ const reachesIntro = (draft : typeof cleaned[number]) : boolean => {
32
+ const seen = new Set<string>();
33
+ let current : typeof cleaned[number] | undefined = draft;
34
+
35
+ while (current && current !== intro) {
36
+ if (seen.has(current.slug)) return false;
37
+ seen.add(current.slug);
38
+ current = current.parentSlug === null ? undefined : bySlug.get(current.parentSlug);
39
+ }
40
+
41
+ return current === intro;
42
+ };
43
+
44
+ for (const draft of cleaned) {
45
+ if (draft !== intro && ! reachesIntro(draft)) draft.parentSlug = intro.slug;
46
+ }
47
+
48
+ const nextPosition = new Map<string, number>();
49
+ const positioned = (parentSlug : string | null) : number => {
50
+ const key = parentSlug ?? "";
51
+ const position = nextPosition.get(key) ?? 0;
52
+ nextPosition.set(key, position + 1);
53
+ return position;
54
+ };
55
+
56
+ const docs : Array<AppDoc> = [{ ...intro, position: positioned(null) }];
57
+
58
+ for (const draft of cleaned) {
59
+ if (draft !== intro) docs.push({ ...draft, position: positioned(draft.parentSlug) });
60
+ }
61
+
62
+ if (readme !== undefined) {
63
+ docs.push({ slug: "readme", title: "README", markdown: readme, parentSlug: null, position: positioned(null) });
64
+ }
65
+
66
+ return docs;
67
+ };
68
+
69
+ /* Generates an app's user documentation from its source and stores it. It runs
70
+ off the deploy's critical path (the build layer calls it fire-and-forget), so
71
+ it is deliberately all-or-nothing and never throws: any failure - no model
72
+ configured, a slow or erroring model, unreadable source - is logged and the
73
+ existing docs are left as they are. */
74
+ class DocGenerator {
75
+
76
+ constructor(private model : DocModel, private store : AppDocsStore) {}
77
+
78
+ async generate(appName : string, appDir : string, options : { redeploy : boolean }) : Promise<void> {
79
+ try {
80
+ const { files, truncated } = await gatherSources(appDir);
81
+ if (files.length === 0) return;
82
+
83
+ const drafts = await this.model.writeDocs({ appName, files, truncated });
84
+ const readme = files.find(file => README.test(file.path))?.content;
85
+ const docs = buildDocTree(drafts, readme);
86
+ if (docs.length === 0) return;
87
+
88
+ await this.store.replaceForApp(appName, docs);
89
+ console.log(`[docs] ${options.redeploy ? "regenerated" : "generated"} ${docs.length} doc(s) for "${appName}"`);
90
+ } catch (error) {
91
+ console.error(`[docs] could not generate documentation for "${appName}": ${error instanceof Error ? error.message : error}`);
92
+ }
93
+ }
94
+
95
+ }
96
+
97
+ export { DocGenerator, buildDocTree }
@@ -0,0 +1,27 @@
1
+ type DocContext = {
2
+ appName : string,
3
+ files : Array<{ path : string, content : string }>,
4
+ truncated : boolean,
5
+ };
6
+
7
+ /* One doc as the model proposes it, before the generator arranges the set into
8
+ a tree: a slug, a title, its markdown, and optionally the slug of the doc it
9
+ belongs under (the introduction, or another page). The generator fills in the
10
+ final parent and sibling position. */
11
+ type DraftDoc = {
12
+ slug : string,
13
+ title : string,
14
+ markdown : string,
15
+ parentSlug? : string | null,
16
+ };
17
+
18
+ /* Turns an app's source into a set of user-facing docs. An implementation that
19
+ cannot reach a model (nothing configured) returns an empty set rather than
20
+ throwing, so doc generation quietly does nothing. */
21
+ interface DocModel {
22
+
23
+ writeDocs(context : DocContext) : Promise<Array<DraftDoc>>;
24
+
25
+ }
26
+
27
+ export type { DocContext, DraftDoc, DocModel };
@@ -0,0 +1,86 @@
1
+ import {DocContext, DocModel, DraftDoc} from "./DocModel";
2
+
3
+ type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
4
+
5
+ const SYSTEM_PROMPT = `You write user documentation for an application deployed on Anbaric.
6
+
7
+ 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.
8
+
9
+ Organise the docs as a shallow tree. Write exactly one top-level page with the slug "introduction" and a null parentSlug: a concise overview of the app that orients a new user. Write every other page as a child, giving its parentSlug as "introduction" (or, to nest one level deeper, the slug of another page). Prefer several focused child pages over one long page. Do not write a page with the slug "readme" - the app's README is added separately.
10
+
11
+ Write clear markdown. Give each page 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.`;
12
+
13
+ const OUTPUT_SCHEMA = {
14
+ type: "object",
15
+ additionalProperties: false,
16
+ properties: {
17
+ docs: {
18
+ type: "array",
19
+ items: {
20
+ type: "object",
21
+ additionalProperties: false,
22
+ properties: {
23
+ slug: { type: "string" },
24
+ title: { type: "string" },
25
+ markdown: { type: "string" },
26
+ parentSlug: { type: ["string", "null"] },
27
+ },
28
+ required: ["slug", "title", "markdown", "parentSlug"],
29
+ },
30
+ },
31
+ },
32
+ required: ["docs"],
33
+ };
34
+
35
+ /* The platform's doc writer over an OpenAI-compatible chat endpoint - the same
36
+ gateway the agentic actions use (ANBARIC_AI_GATEWAY_*), so no new config.
37
+ With no token there is nothing to call, so it returns no docs and the
38
+ generator does nothing. */
39
+ class GatewayDocModel implements DocModel {
40
+
41
+ constructor(private baseUrl : string = process.env.ANBARIC_AI_GATEWAY_URL ?? "https://api.openai.com/v1",
42
+ private apiKey : string = process.env.ANBARIC_AI_GATEWAY_TOKEN ?? "",
43
+ private model : string = process.env.ANBARIC_AGENTIC_MODEL ?? "gpt-5.4-mini",
44
+ private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
45
+
46
+ async writeDocs(context : DocContext) : Promise<Array<DraftDoc>> {
47
+ if (!this.apiKey) {
48
+ console.warn(`[docs] no ANBARIC_AI_GATEWAY_TOKEN - skipping documentation for "${context.appName}"`);
49
+ return [];
50
+ }
51
+
52
+ const response = await this.fetchFn(`${this.baseUrl.replace(/\/$/, "")}/chat/completions`, {
53
+ method: "POST",
54
+ headers: { "content-type": "application/json", "authorization": `Bearer ${this.apiKey}` },
55
+ body: JSON.stringify({
56
+ model: this.model,
57
+ messages: [
58
+ { role: "system", content: SYSTEM_PROMPT },
59
+ { role: "user", content: this.render(context) },
60
+ ],
61
+ response_format: {
62
+ type: "json_schema",
63
+ json_schema: { name: "docs", strict: true, schema: OUTPUT_SCHEMA },
64
+ },
65
+ }),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ const problem = await response.json().catch(() => ({}));
70
+ throw new Error(`Doc model request failed with status ${response.status}${problem.error?.message ? `: ${problem.error.message}` : ""}`);
71
+ }
72
+
73
+ const message = (await response.json()).choices?.[0]?.message?.content;
74
+ const docs = message ? JSON.parse(message).docs : [];
75
+ return Array.isArray(docs) ? docs : [];
76
+ }
77
+
78
+ private render(context : DocContext) : string {
79
+ const header = `App "${context.appName}"${context.truncated ? " (source was truncated - some files are omitted)" : ""}. Source:`;
80
+ const files = context.files.map(file => `\n\n=== ${file.path} ===\n${file.content}`).join("");
81
+ return header + files;
82
+ }
83
+
84
+ }
85
+
86
+ 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 };
package/src/index.ts CHANGED
@@ -24,6 +24,12 @@ export * from "./app-admin/adminPing";
24
24
  export * from "./data-store/PostgresCliKeyStore";
25
25
  export * from "./data-store/PostgresJobPersistence";
26
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";
27
33
  export * from "./data-store/PostgresJsonStore";
28
34
  export * from "./data-store/Schema";
29
35
  export * from "./data-store/SecretsManagerSecretStore";
package/src/main.ts CHANGED
@@ -17,6 +17,9 @@ import {PostgresJsonStore} from "./data-store/PostgresJsonStore";
17
17
  import {PostgresQueue} from "./queuing/PostgresQueue";
18
18
  import {DockerBuildLayer} from "./app-management/DockerBuildLayer";
19
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";
20
23
  import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
21
24
  import {Dispatcher} from "./queuing/Dispatcher";
22
25
  import {HostingServer} from "./hosting/HostingServer";
@@ -32,6 +35,10 @@ const hostingPort = Number(process.env.ANBARIC_HOSTING_PORT ?? 8787);
32
35
  const internalPort = Number(process.env.ANBARIC_INTERNAL_PORT ?? 8788);
33
36
  const appsDir = process.env.ANBARIC_APPS_DIR ?? "/tmp/anbaric-apps";
34
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
+
35
42
  const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
36
43
  ? new DockerBuildLayer(appsDir, {
37
44
  baseImage: process.env.ANBARIC_APP_BASE_IMAGE ?? "anbaric-v2-platform:local",
@@ -39,6 +46,7 @@ const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
39
46
  platformUrl: process.env.ANBARIC_PLATFORM_INTERNAL_URL ?? `http://localhost:${internalPort}`,
40
47
  sqlDatabaseUrl: process.env.ANBARIC_APP_SQL_DATABASE_URL,
41
48
  sqlSchema: process.env.ANBARIC_SQL_SCHEMA,
49
+ docGenerator,
42
50
  })
43
51
  : process.env.ANBARIC_BUILD_LAYER === "fargate"
44
52
  ? new FargateBuildLayer(appsDir, {
@@ -60,6 +68,7 @@ const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
60
68
  servicesApiKey: process.env.ANBARIC_SERVICES_API_KEY,
61
69
  sqlDatabaseUrlSecretArn: process.env.ANBARIC_AWS_APP_SQL_URL_SECRET,
62
70
  sqlSchema: process.env.ANBARIC_SQL_SCHEMA,
71
+ docGenerator,
63
72
  })
64
73
  : undefined;
65
74