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.
|
|
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.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
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.
|
|
32
|
-
"anbaric-state-machine": "^1.
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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,
|
|
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
|
-
|
|
38
|
-
|
|
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
|
}
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -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)
|
|
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 (
|
package/src/docs/DocGenerator.ts
CHANGED
|
@@ -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
|
|
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 }
|
package/src/docs/DocModel.ts
CHANGED
|
@@ -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<
|
|
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 {
|
|
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.
|
|
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<
|
|
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 [];
|