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 +7 -7
- package/src/app-management/BaseBuildLayer.ts +8 -0
- package/src/app-management/DockerBuildLayer.ts +3 -0
- package/src/app-management/FargateBuildLayer.ts +3 -0
- package/src/data-store/AppDocsStore.ts +23 -0
- package/src/data-store/InMemoryAppDocsStore.ts +17 -0
- package/src/data-store/PostgresAppDocsStore.ts +56 -0
- package/src/data-store/Schema.ts +18 -0
- package/src/docs/DocGenerator.ts +97 -0
- package/src/docs/DocModel.ts +27 -0
- package/src/docs/GatewayDocModel.ts +86 -0
- package/src/docs/gatherSources.ts +79 -0
- package/src/index.ts +6 -0
- package/src/main.ts +9 -0
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,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 }
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -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
|
|