anbaric-cloud-hosting 1.27.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.27.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.27.0",
22
- "anbaric-plugins": "^1.27.0",
23
- "anbaric-tsapi": "^1.27.0",
24
- "anbaric-web": "^1.27.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.27.0",
32
- "anbaric-state-machine": "^1.27.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,12 +2,16 @@ type AppDoc = {
2
2
  slug : string,
3
3
  title : string,
4
4
  markdown : string,
5
+ parentSlug : string | null,
6
+ position : number,
5
7
  };
6
8
 
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. */
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. */
11
15
  interface AppDocsStore {
12
16
 
13
17
  replaceForApp(appId : string, docs : Array<AppDoc>) : Promise<void>;
@@ -17,9 +17,14 @@ class PostgresAppDocsStore implements AppDocsStore {
17
17
 
18
18
  if (docs.length > 0) {
19
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)],
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)],
23
28
  );
24
29
  }
25
30
 
@@ -34,8 +39,16 @@ class PostgresAppDocsStore implements AppDocsStore {
34
39
 
35
40
  async listForApp(appId : string) : Promise<Array<AppDoc>> {
36
41
  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 }));
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
+ }));
39
52
  }
40
53
 
41
54
  }
@@ -95,17 +95,23 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
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
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.
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.
99
101
  await pool.query(`
100
102
  CREATE TABLE IF NOT EXISTS app_docs (
101
103
  app_id TEXT NOT NULL,
102
104
  slug TEXT NOT NULL,
103
105
  title TEXT NOT NULL DEFAULT '',
104
106
  markdown TEXT NOT NULL,
107
+ parent_slug TEXT,
108
+ position INTEGER NOT NULL DEFAULT 0,
105
109
  generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
106
110
  PRIMARY KEY (app_id, slug)
107
111
  )
108
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");
109
115
  await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
110
116
  await pool.query(`
111
117
  CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
@@ -1,7 +1,71 @@
1
- import {AppDocsStore} from "../data-store/AppDocsStore";
2
- import {DocModel} from "./DocModel";
1
+ import {AppDoc, AppDocsStore} from "../data-store/AppDocsStore";
2
+ import {DocModel, DraftDoc} from "./DocModel";
3
3
  import {gatherSources} from "./gatherSources";
4
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
+
5
69
  /* Generates an app's user documentation from its source and stores it. It runs
6
70
  off the deploy's critical path (the build layer calls it fire-and-forget), so
7
71
  it is deliberately all-or-nothing and never throws: any failure - no model
@@ -16,7 +80,9 @@ class DocGenerator {
16
80
  const { files, truncated } = await gatherSources(appDir);
17
81
  if (files.length === 0) return;
18
82
 
19
- const docs = await this.model.writeDocs({ appName, files, truncated });
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);
20
86
  if (docs.length === 0) return;
21
87
 
22
88
  await this.store.replaceForApp(appName, docs);
@@ -28,4 +94,4 @@ class DocGenerator {
28
94
 
29
95
  }
30
96
 
31
- export { DocGenerator }
97
+ export { DocGenerator, buildDocTree }
@@ -1,18 +1,27 @@
1
- import {AppDoc} from "../data-store/AppDocsStore";
2
-
3
1
  type DocContext = {
4
2
  appName : string,
5
3
  files : Array<{ path : string, content : string }>,
6
4
  truncated : boolean,
7
5
  };
8
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
+
9
18
  /* Turns an app's source into a set of user-facing docs. An implementation that
10
19
  cannot reach a model (nothing configured) returns an empty set rather than
11
20
  throwing, so doc generation quietly does nothing. */
12
21
  interface DocModel {
13
22
 
14
- writeDocs(context : DocContext) : Promise<Array<AppDoc>>;
23
+ writeDocs(context : DocContext) : Promise<Array<DraftDoc>>;
15
24
 
16
25
  }
17
26
 
18
- export type { DocContext, DocModel };
27
+ export type { DocContext, DraftDoc, DocModel };
@@ -1,11 +1,14 @@
1
- import {AppDoc} from "../data-store/AppDocsStore";
2
- import {DocContext, DocModel} from "./DocModel";
1
+ import {DocContext, DocModel, DraftDoc} from "./DocModel";
3
2
 
4
3
  type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
5
4
 
6
5
  const SYSTEM_PROMPT = `You write user documentation for an application deployed on Anbaric.
7
6
 
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.`;
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.`;
9
12
 
10
13
  const OUTPUT_SCHEMA = {
11
14
  type: "object",
@@ -20,8 +23,9 @@ const OUTPUT_SCHEMA = {
20
23
  slug: { type: "string" },
21
24
  title: { type: "string" },
22
25
  markdown: { type: "string" },
26
+ parentSlug: { type: ["string", "null"] },
23
27
  },
24
- required: ["slug", "title", "markdown"],
28
+ required: ["slug", "title", "markdown", "parentSlug"],
25
29
  },
26
30
  },
27
31
  },
@@ -39,7 +43,7 @@ class GatewayDocModel implements DocModel {
39
43
  private model : string = process.env.ANBARIC_AGENTIC_MODEL ?? "gpt-5.4-mini",
40
44
  private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
41
45
 
42
- async writeDocs(context : DocContext) : Promise<Array<AppDoc>> {
46
+ async writeDocs(context : DocContext) : Promise<Array<DraftDoc>> {
43
47
  if (!this.apiKey) {
44
48
  console.warn(`[docs] no ANBARIC_AI_GATEWAY_TOKEN - skipping documentation for "${context.appName}"`);
45
49
  return [];