@fungi.computer/oyster 0.0.1 → 0.1.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.
Files changed (171) hide show
  1. package/README.md +228 -176
  2. package/dist/app-builds.d.ts +76 -17
  3. package/dist/app-builds.js +207 -63
  4. package/dist/app-sources.d.ts +7 -22
  5. package/dist/app-sources.js +49 -14
  6. package/dist/build/artifact-writer.d.ts +40 -0
  7. package/dist/build/artifact-writer.js +181 -0
  8. package/dist/build/build-jobs.js +7 -1
  9. package/dist/build/build-policy.d.ts +10 -0
  10. package/dist/build/build-policy.js +10 -0
  11. package/dist/build/build-step.d.ts +12 -1
  12. package/dist/build/build-step.js +9 -1
  13. package/dist/build/candidate-builder.d.ts +68 -0
  14. package/dist/build/candidate-builder.js +91 -0
  15. package/dist/build/candidate-cli.d.ts +1 -0
  16. package/dist/build/candidate-cli.js +119 -0
  17. package/dist/build/candidate-cli.mjs +28170 -0
  18. package/dist/build/content-objects.d.ts +37 -0
  19. package/dist/build/content-objects.js +53 -0
  20. package/dist/build/effect.d.ts +18 -0
  21. package/dist/build/effect.js +52 -12
  22. package/dist/build/git-environment.d.ts +3 -0
  23. package/dist/build/git-environment.js +21 -9
  24. package/dist/build/local-build.d.ts +13 -3
  25. package/dist/build/local-build.js +149 -99
  26. package/dist/build/snapshot-package-store.d.ts +10 -0
  27. package/dist/build/snapshot-package-store.js +15 -0
  28. package/dist/build/snapshot-program-scripts.js +1 -1
  29. package/dist/build/snapshot-programs/snapshot-path.d.ts +2 -0
  30. package/dist/build/snapshot-programs/snapshot-path.js +6 -0
  31. package/dist/build/submission-contract.d.ts +461 -17
  32. package/dist/build/submission-contract.js +136 -26
  33. package/dist/build/workspace-build.d.ts +7 -2
  34. package/dist/build/workspace-build.js +55 -11
  35. package/dist/build-contract.d.ts +3 -0
  36. package/dist/build-contract.js +3 -0
  37. package/dist/build-egress.d.ts +62 -0
  38. package/dist/build-egress.js +148 -0
  39. package/dist/build.d.ts +1 -0
  40. package/dist/build.js +1 -0
  41. package/dist/mcp-owner.js +20 -8
  42. package/dist/pack.d.ts +6 -3
  43. package/dist/pack.js +28 -11
  44. package/dist/page/gill-observation.d.ts +4 -0
  45. package/dist/page/gill-observation.js +31 -0
  46. package/dist/page/window-launch.d.ts +18 -0
  47. package/dist/page/window-launch.js +11 -0
  48. package/dist/page-script.js +2 -2
  49. package/dist/release.d.ts +11 -0
  50. package/dist/release.js +4 -1
  51. package/dist/shell/app-directory.d.ts +13 -8
  52. package/dist/shell/app-directory.js +4 -0
  53. package/dist/shell/shell-module.d.ts +26 -2
  54. package/dist/shell/shell-module.js +51 -2
  55. package/dist/shell/workbench-directory.d.ts +332 -0
  56. package/dist/shell/workbench-directory.js +35 -0
  57. package/dist/source/index.d.ts +6 -7
  58. package/dist/source/index.js +6 -7
  59. package/dist/source/template-push.d.ts +33 -0
  60. package/dist/source/template-push.js +223 -0
  61. package/dist/source/template-repository.d.ts +2 -0
  62. package/dist/source/template-repository.js +13 -0
  63. package/dist/source/{template-write.d.ts → template-seed.d.ts} +19 -20
  64. package/dist/source/template-seed.js +80 -0
  65. package/dist/standalone/agent-directory.d.ts +4 -0
  66. package/dist/standalone/agent-directory.js +4 -0
  67. package/dist/standalone/agents.d.ts +220 -0
  68. package/dist/standalone/agents.js +795 -0
  69. package/dist/standalone/app-builds-binding.d.ts +36 -0
  70. package/dist/standalone/app-builds-binding.js +119 -0
  71. package/dist/standalone/app-set.d.ts +91 -0
  72. package/dist/standalone/app-set.js +395 -0
  73. package/dist/standalone/app-source.d.ts +41 -0
  74. package/dist/standalone/app-source.js +59 -0
  75. package/dist/standalone/builds.d.ts +18 -0
  76. package/dist/standalone/builds.js +101 -0
  77. package/dist/standalone/computer-egress.d.ts +37 -0
  78. package/dist/standalone/computer-egress.js +111 -0
  79. package/dist/standalone/computer.d.ts +122 -0
  80. package/dist/standalone/computer.js +714 -0
  81. package/dist/standalone/install-apps-cli.d.ts +2 -0
  82. package/dist/standalone/install-apps-cli.js +126 -0
  83. package/dist/standalone/instance.d.ts +35 -0
  84. package/dist/standalone/instance.js +74 -0
  85. package/dist/standalone/media.d.ts +76 -0
  86. package/dist/standalone/media.js +227 -0
  87. package/dist/standalone/preview.d.ts +51 -0
  88. package/dist/standalone/preview.js +167 -0
  89. package/dist/standalone/registry.d.ts +102 -0
  90. package/dist/standalone/registry.js +193 -0
  91. package/dist/standalone/tenancy.d.ts +42 -0
  92. package/dist/standalone/tenancy.js +241 -0
  93. package/dist/standalone/worker.d.ts +55 -0
  94. package/dist/standalone/worker.js +147 -0
  95. package/dist/standalone/workflow-authority-readers.d.ts +16 -0
  96. package/dist/standalone/workflow-authority-readers.js +35 -0
  97. package/dist/standalone/workflow-binding.d.ts +216 -0
  98. package/dist/standalone/workflow-binding.js +217 -0
  99. package/dist/standalone/workflow-events.d.ts +46 -0
  100. package/dist/standalone/workflow-events.js +39 -0
  101. package/dist/standalone/workflow-git-capture.d.ts +7 -0
  102. package/dist/standalone/workflow-git-capture.js +62 -0
  103. package/dist/standalone/workflow-host.d.ts +931 -0
  104. package/dist/standalone/workflow-host.js +751 -0
  105. package/dist/standalone/workflow-node-capture.d.ts +13 -0
  106. package/dist/standalone/workflow-node-capture.js +72 -0
  107. package/dist/standalone/workflow-root-authority.d.ts +27 -0
  108. package/dist/standalone/workflow-root-authority.js +87 -0
  109. package/dist/standalone/workflow-schedules.d.ts +52 -0
  110. package/dist/standalone/workflow-schedules.js +40 -0
  111. package/dist/standalone/workflow-trigger-host.d.ts +1694 -0
  112. package/dist/standalone/workflow-trigger-host.js +52 -0
  113. package/dist/standalone/workflows.d.ts +657 -0
  114. package/dist/standalone/workflows.js +554 -0
  115. package/dist/tenancy/auth0.d.ts +64 -0
  116. package/dist/tenancy/auth0.js +198 -0
  117. package/dist/tenancy/cloudflare/rate-limit.d.ts +8 -0
  118. package/dist/tenancy/cloudflare/rate-limit.js +19 -0
  119. package/dist/tenancy/cloudflare/registry.d.ts +50 -0
  120. package/dist/tenancy/cloudflare/registry.js +301 -0
  121. package/dist/tenancy/cloudflare.d.ts +2 -0
  122. package/dist/tenancy/cloudflare.js +2 -0
  123. package/dist/tenancy/cold-directory.d.ts +13 -0
  124. package/dist/tenancy/cold-directory.js +43 -0
  125. package/dist/tenancy/contracts.d.ts +195 -0
  126. package/dist/tenancy/contracts.js +1 -0
  127. package/dist/tenancy/index.d.ts +19 -0
  128. package/dist/tenancy/index.js +156 -0
  129. package/dist/tenancy/owner-key.d.ts +26 -0
  130. package/dist/tenancy/owner-key.js +63 -0
  131. package/dist/tenancy/oyster.d.ts +4 -0
  132. package/dist/tenancy/oyster.js +25 -0
  133. package/dist/tenancy/registry-record.d.ts +7 -0
  134. package/dist/tenancy/registry-record.js +44 -0
  135. package/dist/tenancy/registry.d.ts +27 -0
  136. package/dist/tenancy/registry.js +138 -0
  137. package/dist/tenancy/resolve.d.ts +7 -0
  138. package/dist/tenancy/resolve.js +64 -0
  139. package/dist/workers.d.ts +2 -2
  140. package/dist/workers.js +3 -1
  141. package/docs/api/build-contract.md +829 -32
  142. package/docs/api/build.md +253 -17
  143. package/docs/api/modules.md +40 -32
  144. package/docs/api/pack.md +6 -3
  145. package/docs/api/shell/app-directory.md +267 -0
  146. package/docs/api/shell/shell-module.md +64 -3
  147. package/docs/api/shell/workbench-directory.md +119 -0
  148. package/docs/api/shell/workbench.md +19 -0
  149. package/docs/api/shell.md +127 -0
  150. package/docs/api/tenancy/auth0.md +110 -0
  151. package/docs/api/tenancy/cloudflare.md +221 -0
  152. package/docs/api/tenancy/cold-directory.md +46 -0
  153. package/docs/api/tenancy/owner-key.md +63 -0
  154. package/docs/api/tenancy/registry.md +77 -0
  155. package/docs/api/tenancy/resolve.md +69 -0
  156. package/docs/api/tenancy.md +430 -0
  157. package/docs/api/workers-git.md +140 -21
  158. package/docs/api/workers.md +15 -2
  159. package/docs/site/agent-quickstart.mdx +52 -55
  160. package/examples/standalone/worker.ts +7 -14
  161. package/examples/standalone/wrangler.jsonc +67 -6
  162. package/package.json +72 -21
  163. package/dist/build/r2-artifact-writer.d.ts +0 -31
  164. package/dist/build/r2-artifact-writer.js +0 -179
  165. package/dist/source/template-write.js +0 -344
  166. package/docs/media/architecture.mmd +0 -32
  167. package/docs/media/architecture.png +0 -0
  168. package/docs/media/bonsai-network.png +0 -0
  169. package/docs/media/bonsai-repository.png +0 -0
  170. package/examples/git/worker.ts +0 -39
  171. package/examples/git/wrangler.jsonc +0 -32
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Oyster's one immutable artifact representation: digest-addressed bytes
3
+ * keyed by artifact reference, with media types carried by the App build
4
+ * and asset manifests (never by the store). The local packer writes it to a
5
+ * directory, and a host's build owner writes it to that host's artifact store,
6
+ * so a Release built on the host and one packed and uploaded share objects.
7
+ *
8
+ * It has two halves. Staging turns a build's bytes into content-addressed
9
+ * objects and the output manifest that names them by digest and size. The
10
+ * root half turns an output manifest into the small objects only the store's
11
+ * owner writes: the asset manifest and the App root. The local packer runs
12
+ * both; a host whose builder uploaded the objects itself runs only the root
13
+ * half, over the same manifest, so both write identical root bytes.
14
+ *
15
+ * Every object is derived and checked before the first is stored: an asset
16
+ * build that its policy refuses, or a root over its bound, stores nothing.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import mime from "mime";
20
+ import * as Schema from "effect/Schema";
21
+ import { assetBuildJsonSchema, assetPathSchema, decodeAssetBuild, } from "@fungi.computer/puffball/assets";
22
+ import { appBuildJsonSchema, createAppBuild, } from "@fungi.computer/puffball/app";
23
+ import { parseArtifactRef, parseSha256Digest, } from "@fungi.computer/puffball";
24
+ function sha256(bytes) {
25
+ return parseSha256Digest(`sha256:${createHash("sha256").update(bytes).digest("hex")}`);
26
+ }
27
+ /** The content address of an object: `artifact:sha256-<hex>`. */
28
+ function contentArtifactRef(digest) {
29
+ return parseArtifactRef(`artifact:sha256-${digest.slice("sha256:".length)}`);
30
+ }
31
+ /**
32
+ * The root bound for an App whose recipe declares no asset policy: this
33
+ * writer's private fallback, not recipe policy. An asset recipe's own
34
+ * `maxManifestBytes` bounds its root as it bounds its asset manifest.
35
+ */
36
+ const fallbackRootBytes = 1024 * 1024;
37
+ /**
38
+ * Stage a build's bytes: the output manifest that names each object by its
39
+ * digest and size, and each distinct object once.
40
+ */
41
+ export function stageBuildOutput(output) {
42
+ const objects = [];
43
+ const stage = (bytes) => {
44
+ const digest = sha256(bytes);
45
+ if (!objects.some((object) => object.digest === digest))
46
+ objects.push({
47
+ artifactRef: contentArtifactRef(digest),
48
+ bytes: bytes.slice(),
49
+ digest,
50
+ });
51
+ return { digest, byteLength: bytes.byteLength };
52
+ };
53
+ const declared = output.declaration === undefined ? {} : { declaration: output.declaration };
54
+ const ui = (prepared) => prepared.content.kind === "html"
55
+ ? {
56
+ mediaType: "text/html; charset=utf-8",
57
+ content: { kind: "html", ...stage(prepared.content.bytes) },
58
+ }
59
+ : {
60
+ mediaType: "application/vnd.fungi.app-assets+json",
61
+ content: {
62
+ kind: "assets",
63
+ base: prepared.content.base,
64
+ assets: prepared.content.assets.map((asset) => ({
65
+ path: asset.path,
66
+ ...stage(asset.bytes),
67
+ })),
68
+ },
69
+ };
70
+ const manifest = output.kind === "ui"
71
+ ? { kind: "ui", ui: ui(output.ui), ...declared }
72
+ : output.kind === "backend"
73
+ ? { kind: "backend", backend: stage(output.backend), ...declared }
74
+ : {
75
+ kind: "app",
76
+ ui: ui(output.ui),
77
+ backend: stage(output.backend),
78
+ ...declared,
79
+ };
80
+ return { manifest, objects };
81
+ }
82
+ /**
83
+ * Derive the asset manifest (for an asset UI) and the App root an output
84
+ * manifest names, each checked against the policy, without storing either.
85
+ */
86
+ function artifactRootObjects(manifest, options) {
87
+ const objects = [];
88
+ const referenced = (object) => ({
89
+ artifactRef: contentArtifactRef(object.digest),
90
+ digest: object.digest,
91
+ version: object.digest,
92
+ byteLength: object.byteLength,
93
+ });
94
+ const stage = (bytes, ref) => {
95
+ const digest = sha256(bytes);
96
+ const artifactRef = ref ?? contentArtifactRef(digest);
97
+ objects.push({ artifactRef, bytes, digest });
98
+ return {
99
+ artifactRef,
100
+ digest,
101
+ version: digest,
102
+ byteLength: bytes.byteLength,
103
+ };
104
+ };
105
+ const ui = manifest.kind === "backend" ? null : manifest.ui;
106
+ const storedUi = (() => {
107
+ if (ui === null)
108
+ return null;
109
+ if (ui.content.kind === "html")
110
+ return { ...referenced(ui.content), mediaType: ui.mediaType };
111
+ if (!options.assetPolicy)
112
+ throw new Error("Asset writer requires explicit policy");
113
+ const byPath = new Map();
114
+ for (const asset of ui.content.assets) {
115
+ const path = Schema.decodeUnknownSync(assetPathSchema)(asset.path);
116
+ if (byPath.has(path))
117
+ throw new Error("Duplicate build path");
118
+ byPath.set(path, asset);
119
+ }
120
+ const assets = [];
121
+ for (const [path, asset] of [...byPath].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
122
+ const mediaType = mime.getType(path);
123
+ const contentType = mediaType?.startsWith("text/")
124
+ ? `${mediaType}; charset=utf-8`
125
+ : mediaType;
126
+ if (!contentType)
127
+ throw new Error("Build asset has no maintained content type");
128
+ assets.push({ path, ...referenced(asset), contentType });
129
+ }
130
+ const assetManifest = new TextEncoder().encode(Schema.encodeUnknownSync(assetBuildJsonSchema)({
131
+ format: "fungi-assets-v1",
132
+ base: ui.content.base,
133
+ assets,
134
+ }));
135
+ decodeAssetBuild(assetManifest, options.assetPolicy);
136
+ return { ...stage(assetManifest), mediaType: ui.mediaType };
137
+ })();
138
+ const storedBackend = manifest.kind === "ui"
139
+ ? null
140
+ : {
141
+ ...referenced(manifest.backend),
142
+ mediaType: "application/vnd.fungi.app-backend+json",
143
+ };
144
+ const rootBytes = new TextEncoder().encode(Schema.encodeUnknownSync(appBuildJsonSchema)(createAppBuild(storedUi, storedBackend, manifest.declaration)));
145
+ if (rootBytes.byteLength >
146
+ (options.assetPolicy?.maxManifestBytes ?? fallbackRootBytes))
147
+ throw new Error("App root exceeds its manifest bound");
148
+ const root = stage(rootBytes, options.rootArtifactRef);
149
+ return {
150
+ objects,
151
+ root: Object.freeze({
152
+ artifactRef: root.artifactRef,
153
+ artifactDigest: root.digest,
154
+ }),
155
+ };
156
+ }
157
+ /** Store each object once; an existing object must hold the same bytes. */
158
+ async function storeObjects(staged, objects) {
159
+ for (const { artifactRef, bytes, digest } of staged)
160
+ if ((await objects.create(artifactRef, bytes)) === "exists") {
161
+ const existing = await objects.read(artifactRef);
162
+ if (existing === null || sha256(existing) !== digest)
163
+ throw new Error("Immutable artifact collision");
164
+ }
165
+ }
166
+ /**
167
+ * Write the asset manifest and App root an output manifest names. The
168
+ * objects it names are already stored, and checked, by the caller.
169
+ */
170
+ export async function writeArtifactRoot(manifest, objects, options) {
171
+ const derived = artifactRootObjects(manifest, options);
172
+ await storeObjects(derived.objects, objects);
173
+ return derived.root;
174
+ }
175
+ /** Persist one build's UI, backend and root; an existing object must hold the same bytes. */
176
+ export async function writeImmutableArtifact(output, objects, options) {
177
+ const staged = stageBuildOutput(output);
178
+ const derived = artifactRootObjects(staged.manifest, options);
179
+ await storeObjects([...staged.objects, ...derived.objects], objects);
180
+ return derived.root;
181
+ }
@@ -52,6 +52,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
52
52
  });
53
53
  import * as Schema from "effect/Schema";
54
54
  import { base64url } from "jose";
55
+ import { workspaceBuildPolicy } from "./build-policy.js";
55
56
  /** The secret-free Team job; no provider token enters publication. */
56
57
  const jobSchema = Schema.Struct({
57
58
  jobId: Schema.NonEmptyString,
@@ -177,6 +178,8 @@ export function createTeamBuildPublisher(options) {
177
178
  export function teamAppBuildCommand(configuration, uploadGrant) {
178
179
  const encoded = base64url.encode(JSON.stringify({
179
180
  uploadGrant,
181
+ // A builder that predates this policy refuses the whole request.
182
+ buildPolicy: workspaceBuildPolicy,
180
183
  appId: configuration.appId,
181
184
  sourceRevision: configuration.sourceRevision,
182
185
  recipe: configuration.source.recipe,
@@ -191,7 +194,10 @@ export function teamAppBuildCommand(configuration, uploadGrant) {
191
194
  `trap 'rm -rf "$work"' EXIT`,
192
195
  'mkdir "$work/tmp"',
193
196
  'export TMPDIR="$work/tmp"',
197
+ "clone_started=$(date +%s%3N)",
194
198
  'git clone -q --no-checkout -- "$TEAM_BUILD_REMOTE" "$work/source" 2>/dev/null',
195
- `node /opt/fungi/team-candidate-builder.mjs '${encoded}' "$work/source" "$work/candidate.json"`,
199
+ // The builder reports each phase's duration with its outcome.
200
+ "export TEAM_BUILD_CLONE_MS=$(( $(date +%s%3N) - clone_started ))",
201
+ `node /opt/fungi/team-candidate-builder.mjs '${encoded}' "$work/source" "$work/candidate"`,
196
202
  ].join("\n");
197
203
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The pinned workspace build policy: how a captured workspace's manifests
3
+ * build (`package.json#fungi.build`). v2 lets a dependency name its own
4
+ * package build script, which only an admitted Computer build runs.
5
+ *
6
+ * It is part of every workspace HTML recipe's toolchain, so its digest moves
7
+ * with it, and the Computer build request carries it at the top level, where
8
+ * a builder compiled before it existed refuses the request outright.
9
+ */
10
+ export declare const workspaceBuildPolicy = "package-json-fungi-build-v2";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The pinned workspace build policy: how a captured workspace's manifests
3
+ * build (`package.json#fungi.build`). v2 lets a dependency name its own
4
+ * package build script, which only an admitted Computer build runs.
5
+ *
6
+ * It is part of every workspace HTML recipe's toolchain, so its digest moves
7
+ * with it, and the Computer build request carries it at the top level, where
8
+ * a builder compiled before it existed refuses the request outright.
9
+ */
10
+ export const workspaceBuildPolicy = "package-json-fungi-build-v2";
@@ -3,7 +3,10 @@ import * as Schema from "effect/Schema";
3
3
  * One captured dependency build step. Paths are relative to the captured
4
4
  * snapshot root. A TypeScript step compiles one config; a Tailwind step
5
5
  * compiles one stylesheet input into its output (the same path for an App's
6
- * own stylesheet, which compiles in place).
6
+ * own stylesheet, which compiles in place). A script step runs the one
7
+ * `package.json` script a workspace dependency names as its build
8
+ * (`fungi.build.script`), with pnpm, in that package's directory; only an
9
+ * admitted Computer build executes it.
7
10
  */
8
11
  export declare const workspaceBuildStepSchema: Schema.Union<readonly [Schema.Struct<{
9
12
  readonly kind: Schema.Literal<"typescript">;
@@ -12,6 +15,10 @@ export declare const workspaceBuildStepSchema: Schema.Union<readonly [Schema.Str
12
15
  readonly kind: Schema.Literal<"tailwind">;
13
16
  readonly input: Schema.String;
14
17
  readonly output: Schema.String;
18
+ }>, Schema.Struct<{
19
+ readonly kind: Schema.Literal<"script">;
20
+ readonly directory: Schema.String;
21
+ readonly script: Schema.String;
15
22
  }>]>;
16
23
  /** One captured dependency build step. */
17
24
  export type WorkspaceBuildStep = Schema.Schema.Type<typeof workspaceBuildStepSchema>;
@@ -23,4 +30,8 @@ export declare const workspaceBuildStepsJsonSchema: Schema.fromJsonString<Schema
23
30
  readonly kind: Schema.Literal<"tailwind">;
24
31
  readonly input: Schema.String;
25
32
  readonly output: Schema.String;
33
+ }>, Schema.Struct<{
34
+ readonly kind: Schema.Literal<"script">;
35
+ readonly directory: Schema.String;
36
+ readonly script: Schema.String;
26
37
  }>]>>>;
@@ -3,7 +3,10 @@ import * as Schema from "effect/Schema";
3
3
  * One captured dependency build step. Paths are relative to the captured
4
4
  * snapshot root. A TypeScript step compiles one config; a Tailwind step
5
5
  * compiles one stylesheet input into its output (the same path for an App's
6
- * own stylesheet, which compiles in place).
6
+ * own stylesheet, which compiles in place). A script step runs the one
7
+ * `package.json` script a workspace dependency names as its build
8
+ * (`fungi.build.script`), with pnpm, in that package's directory; only an
9
+ * admitted Computer build executes it.
7
10
  */
8
11
  export const workspaceBuildStepSchema = Schema.Union([
9
12
  Schema.Struct({
@@ -15,6 +18,11 @@ export const workspaceBuildStepSchema = Schema.Union([
15
18
  input: Schema.String,
16
19
  output: Schema.String,
17
20
  }),
21
+ Schema.Struct({
22
+ kind: Schema.Literal("script"),
23
+ directory: Schema.String,
24
+ script: Schema.String,
25
+ }),
18
26
  ]);
19
27
  /** The steps one dependency-build process runs, encoded as one argument. */
20
28
  export const workspaceBuildStepsJsonSchema = Schema.fromJsonString(Schema.Array(workspaceBuildStepSchema));
@@ -0,0 +1,68 @@
1
+ import * as Schema from "effect/Schema";
2
+ export declare class TeamCandidateBuildError extends Error {
3
+ readonly code: "invalid_recipe" | "output_limit" | "report_failed";
4
+ constructor(code: "invalid_recipe" | "output_limit" | "report_failed");
5
+ }
6
+ export declare const teamCandidateConfigurationSchema: Schema.Struct<{
7
+ /** The build policy the host expects; a builder that predates it refuses
8
+ * the request, and this one refuses any other. */
9
+ readonly buildPolicy: Schema.Literal<"package-json-fungi-build-v2">;
10
+ readonly appId: Schema.refine<import("@fungi.computer/puffball").AppId, Schema.String>;
11
+ readonly sourceRevision: Schema.refine<import("@fungi.computer/puffball").SourceRevision, Schema.String>;
12
+ readonly recipe: Schema.Struct<{
13
+ readonly kind: Schema.optional<Schema.Literals<readonly ["bundled-html", "source-html"]>>;
14
+ readonly bundle: Schema.Literal<true>;
15
+ readonly entry: Schema.String;
16
+ readonly format: Schema.Literals<readonly ["html", "esm", "assets"]>;
17
+ readonly assets: Schema.optional<Schema.Struct<{
18
+ readonly maxManifestBytes: Schema.Number;
19
+ readonly maxObjects: Schema.Number;
20
+ readonly maxObjectBytes: Schema.Number;
21
+ readonly maxTotalBytes: Schema.Number;
22
+ }>>;
23
+ readonly assetBase: Schema.optionalKey<Schema.String>;
24
+ readonly toolchain: Schema.$Record<Schema.String, Schema.String>;
25
+ readonly maxOutputBytes: Schema.Number;
26
+ readonly platform: Schema.Literals<readonly ["browser", "neutral"]>;
27
+ readonly backend: Schema.Union<readonly [Schema.Null, Schema.Struct<{
28
+ readonly entry: Schema.optional<Schema.String>;
29
+ readonly runtime: Schema.Struct<{
30
+ readonly kind: Schema.Literal<"durable-facet">;
31
+ readonly stateSchema: Schema.Number;
32
+ }>;
33
+ readonly compatibilityDate: Schema.String;
34
+ readonly exportName: Schema.String;
35
+ }>]>;
36
+ readonly target: Schema.Tuple<readonly [Schema.Literal<"es2022">]>;
37
+ }>;
38
+ readonly buildRecipeDigest: Schema.refine<import("@fungi.computer/puffball").Sha256Digest, Schema.String>;
39
+ }>;
40
+ /** Where a built candidate's manifest and its objects are written. */
41
+ export declare function teamCandidateLayout(outputPath: string): {
42
+ manifest: string;
43
+ object: (digest: string) => string;
44
+ };
45
+ /**
46
+ * Runs inside the admitted Computer, using Oyster's captured builder.
47
+ * This is the one composition that runs a dependency's declared package
48
+ * build script; its executor refuses a recipe pinned under another policy.
49
+ * It writes a new directory: each built object under its digest, and the
50
+ * small manifest that names them, ready to upload one at a time.
51
+ */
52
+ export declare function buildTeamCandidate(input: {
53
+ configuration: unknown;
54
+ repositoryPath: string;
55
+ /** A directory this build creates; it must not exist yet. */
56
+ outputPath: string;
57
+ /** Trusted Computer-local cache; never part of customer configuration. */
58
+ packageStoreDirectory?: string;
59
+ }): Promise<Readonly<{
60
+ digest: string;
61
+ bytes: number;
62
+ /** Milliseconds: capturing the cloned source, installing, compiling. */
63
+ phases: Readonly<{
64
+ capture: number;
65
+ install: number;
66
+ build: number;
67
+ }>;
68
+ }>>;
@@ -0,0 +1,91 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Schema from "effect/Schema";
6
+ import { appIdSchema, sourceRevisionSchema, sha256DigestSchema, } from "@fungi.computer/puffball/effect";
7
+ import { parseReleaseId } from "@fungi.computer/puffball";
8
+ import { capturedPinnedRecipeSchema, createCapturedCandidatePreparer, digestPinnedRecipe, encodeTeamAppCandidateManifest, TEAM_APP_CANDIDATE_MANIFEST_MAX_BYTES, workspaceBuildPolicy, } from "../build-contract.js";
9
+ import { createLocalCommitSourceCapture, createLocalBuildExecutor, stageBuildOutput, } from "../build.js";
10
+ export class TeamCandidateBuildError extends Error {
11
+ code;
12
+ constructor(code) {
13
+ super(`Team candidate ${code}`);
14
+ this.code = code;
15
+ this.name = "TeamCandidateBuildError";
16
+ }
17
+ }
18
+ export const teamCandidateConfigurationSchema = Schema.Struct({
19
+ /** The build policy the host expects; a builder that predates it refuses
20
+ * the request, and this one refuses any other. */
21
+ buildPolicy: Schema.Literal(workspaceBuildPolicy),
22
+ appId: appIdSchema,
23
+ sourceRevision: sourceRevisionSchema,
24
+ recipe: capturedPinnedRecipeSchema,
25
+ buildRecipeDigest: sha256DigestSchema,
26
+ });
27
+ /** Where a built candidate's manifest and its objects are written. */
28
+ export function teamCandidateLayout(outputPath) {
29
+ return {
30
+ manifest: join(outputPath, "manifest.json"),
31
+ object: (digest) => join(outputPath, "objects", digest.replace(":", "-")),
32
+ };
33
+ }
34
+ /**
35
+ * Runs inside the admitted Computer, using Oyster's captured builder.
36
+ * This is the one composition that runs a dependency's declared package
37
+ * build script; its executor refuses a recipe pinned under another policy.
38
+ * It writes a new directory: each built object under its digest, and the
39
+ * small manifest that names them, ready to upload one at a time.
40
+ */
41
+ export async function buildTeamCandidate(input) {
42
+ const config = Schema.decodeUnknownSync(teamCandidateConfigurationSchema, {
43
+ onExcessProperty: "error",
44
+ })(input.configuration);
45
+ if (digestPinnedRecipe(config.recipe) !== config.buildRecipeDigest)
46
+ throw new TeamCandidateBuildError("invalid_recipe");
47
+ const phases = { install: 0, build: 0 };
48
+ const onPhase = (phase, milliseconds) => {
49
+ phases[phase] += milliseconds;
50
+ };
51
+ const prepareStarted = performance.now();
52
+ const candidate = await Effect.runPromise(createCapturedCandidatePreparer(config, {
53
+ sourceCapture: createLocalCommitSourceCapture(input.repositoryPath, config.sourceRevision),
54
+ buildExecutor: createLocalBuildExecutor(input.packageStoreDirectory === undefined
55
+ ? { packageScripts: "run", onPhase }
56
+ : {
57
+ packageScripts: "run",
58
+ packageStoreDirectory: input.packageStoreDirectory,
59
+ onPhase,
60
+ }),
61
+ }).prepareCandidate(parseReleaseId("rel_build")));
62
+ const prepared = performance.now() - prepareStarted;
63
+ const staged = stageBuildOutput(candidate.output);
64
+ const bytes = Buffer.from(encodeTeamAppCandidateManifest({
65
+ appId: candidate.appId,
66
+ output: staged.manifest,
67
+ provenance: candidate.provenance,
68
+ }));
69
+ if (bytes.byteLength > TEAM_APP_CANDIDATE_MANIFEST_MAX_BYTES)
70
+ throw new TeamCandidateBuildError("output_limit");
71
+ const layout = teamCandidateLayout(input.outputPath);
72
+ // A new directory: an earlier build's output is never mixed in.
73
+ await mkdir(input.outputPath, { mode: 0o700 });
74
+ await mkdir(join(input.outputPath, "objects"), { mode: 0o700 });
75
+ for (const object of staged.objects)
76
+ await writeFile(layout.object(object.digest), object.bytes, {
77
+ flag: "wx",
78
+ mode: 0o600,
79
+ });
80
+ await writeFile(layout.manifest, bytes, { flag: "wx", mode: 0o600 });
81
+ return {
82
+ digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
83
+ bytes: bytes.byteLength,
84
+ phases: {
85
+ // Snapshot capture and staging: everything but the install and compile.
86
+ capture: Math.max(0, prepared - phases.install - phases.build),
87
+ install: phases.install,
88
+ build: phases.build,
89
+ },
90
+ };
91
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { releaseSchema } from "@fungi.computer/puffball/effect";
3
+ import { readFile } from "node:fs/promises";
4
+ import { candidateManifestObjects, teamAppCandidateManifestSchema, } from "../build-contract.js";
5
+ import { buildTeamCandidate, teamCandidateConfigurationSchema, teamCandidateLayout, TeamCandidateBuildError, } from "./candidate-builder.js";
6
+ /** Objects in flight at once; each is read from disk only while it uploads. */
7
+ const uploadConcurrency = 4;
8
+ /** Attempts per object when its connection drops before a response. */
9
+ const uploadAttempts = 3;
10
+ /** Report a failed build to the host's build output: its kind, never its text. */
11
+ function reportFailure(cause) {
12
+ process.stderr.write(`${JSON.stringify({
13
+ source: "oyster-candidate-builder",
14
+ failureKind: cause instanceof Error ? "Error" : "NonErrorThrown",
15
+ })}\n`);
16
+ }
17
+ const [configuration, repositoryPath, outputPath] = process.argv.slice(2);
18
+ if (!configuration || !repositoryPath || !outputPath)
19
+ throw new Error("Usage: team-candidate-builder CONFIG_BASE64 REPOSITORY OUTPUT_DIRECTORY");
20
+ try {
21
+ const { uploadGrant, ...config } = Schema.decodeUnknownSync(Schema.Struct({
22
+ ...teamCandidateConfigurationSchema.fields,
23
+ uploadGrant: Schema.String,
24
+ }), { onExcessProperty: "error" })(JSON.parse(Buffer.from(configuration, "base64url").toString("utf8")));
25
+ const { phases, ...artifact } = await buildTeamCandidate({
26
+ configuration: config,
27
+ repositoryPath,
28
+ outputPath,
29
+ // Computer scratch survives captured snapshot cleanup, without a global or durable Workspace store.
30
+ packageStoreDirectory: "/var/tmp/fungi-app-package-store",
31
+ });
32
+ const reportUrl = process.env.TEAM_BUILD_REPORT;
33
+ if (!reportUrl)
34
+ throw new TeamCandidateBuildError("report_failed");
35
+ const layout = teamCandidateLayout(outputPath);
36
+ const manifestBytes = await readFile(layout.manifest);
37
+ const manifest = Schema.decodeUnknownSync(Schema.fromJsonString(teamAppCandidateManifestSchema))(new TextDecoder().decode(manifestBytes));
38
+ // Every object first, each streamed alone; the manifest only names them.
39
+ const uploadStarted = performance.now();
40
+ const pending = [...candidateManifestObjects(manifest.output)];
41
+ // A write-once PUT is safe to repeat, so a dropped connection is retried.
42
+ const put = async (object) => {
43
+ for (let attempt = 1;; attempt += 1) {
44
+ const stored = await fetch(`${reportUrl}/objects/${object.digest.replace(":", "-")}`, {
45
+ method: "PUT",
46
+ headers: {
47
+ "Content-Type": object.mediaType,
48
+ "X-Team-App-Build": uploadGrant,
49
+ },
50
+ body: await readFile(layout.object(object.digest)),
51
+ }).catch((cause) => {
52
+ if (attempt >= uploadAttempts)
53
+ throw cause;
54
+ return null;
55
+ });
56
+ await stored?.body?.cancel();
57
+ if (stored?.ok)
58
+ return;
59
+ if (stored !== null || attempt >= uploadAttempts)
60
+ throw new TeamCandidateBuildError("report_failed");
61
+ }
62
+ };
63
+ const upload = async () => {
64
+ for (let object = pending.shift(); object; object = pending.shift())
65
+ await put(object);
66
+ };
67
+ await Promise.all(Array.from({ length: uploadConcurrency }, upload));
68
+ const candidate = await fetch(`${reportUrl}/candidate`, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ "X-Team-App-Build": uploadGrant,
73
+ },
74
+ body: manifestBytes,
75
+ });
76
+ if (!candidate.ok)
77
+ throw new TeamCandidateBuildError("report_failed");
78
+ const receipt = Schema.decodeUnknownSync(Schema.Struct({
79
+ kind: Schema.Literal("submitted"),
80
+ release: releaseSchema,
81
+ }))(await candidate.json());
82
+ const recorded = await fetch(`${reportUrl}/artifact`, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify(artifact),
86
+ });
87
+ if (!recorded.ok)
88
+ throw new TeamCandidateBuildError("report_failed");
89
+ await recorded.body?.cancel();
90
+ const uploaded = performance.now() - uploadStarted;
91
+ // The success detail reports where the build's time went; the owner reads
92
+ // it with the build. The clone ran before this program (TEAM_BUILD_CLONE_MS).
93
+ const cloned = Number(process.env.TEAM_BUILD_CLONE_MS ?? "0");
94
+ const status = await fetch(`${reportUrl}/status`, {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({
98
+ status: "succeeded",
99
+ detail: JSON.stringify({
100
+ phasesMs: {
101
+ clone: Math.round((Number.isFinite(cloned) ? cloned : 0) + phases.capture),
102
+ install: Math.round(phases.install),
103
+ build: Math.round(phases.build),
104
+ upload: Math.round(uploaded),
105
+ },
106
+ }),
107
+ }),
108
+ });
109
+ if (!status.ok)
110
+ throw new TeamCandidateBuildError("report_failed");
111
+ await status.body?.cancel();
112
+ process.stdout.write(JSON.stringify({ ...artifact, release: receipt.release }));
113
+ }
114
+ catch (cause) {
115
+ reportFailure(cause);
116
+ // Customer source and provider messages can contain secrets; only expose the stage.
117
+ process.stderr.write("Team App source/build failed\n");
118
+ process.exitCode = 1;
119
+ }