@fungi.computer/oyster 0.4.0 → 0.4.1

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.
@@ -97,7 +97,7 @@ export type OysterBuildView = Readonly<{
97
97
  appId: string;
98
98
  releaseId: string;
99
99
  sourceRevision: string;
100
- status: "running" | "succeeded" | "failed" | "revoked";
100
+ status: "running" | "succeeded" | "failed" | "interrupted" | "revoked";
101
101
  detail: string | null;
102
102
  release: Release | null;
103
103
  installation: OysterBuildInstallation | null;
@@ -19,6 +19,7 @@
19
19
  import * as Schema from "effect/Schema";
20
20
  import * as Data from "effect/Data";
21
21
  import * as Option from "effect/Option";
22
+ import { candidateFailureDiagnostic } from "./build/candidate-diagnostic.js";
22
23
  import { parseArtifactRef, parseReleaseId, } from "@fungi.computer/puffball";
23
24
  import { appIdSchema, releaseSchema, sha256DigestSchema, sourceRevisionSchema, } from "@fungi.computer/puffball/effect";
24
25
  import { capturedPinnedRecipeSchema, normalizeAppPublisherRecipe, digestPinnedRecipe, fitsPinnedRecipe, pinnedRecipeObjectBound, } from "./build/effect.js";
@@ -116,7 +117,13 @@ const jobRow = Schema.Struct({
116
117
  credential_id: Schema.String,
117
118
  upload_grant_digest: Schema.String,
118
119
  request_id: Schema.NullOr(Schema.String),
119
- status: Schema.Literals(["running", "succeeded", "failed", "revoked"]),
120
+ status: Schema.Literals([
121
+ "running",
122
+ "succeeded",
123
+ "failed",
124
+ "interrupted",
125
+ "revoked",
126
+ ]),
120
127
  artifact_digest: Schema.NullOr(Schema.String),
121
128
  artifact_bytes: Schema.NullOr(Schema.Number),
122
129
  detail: Schema.NullOr(Schema.String),
@@ -186,12 +193,12 @@ export function createOysterAppBuilds(options) {
186
193
  // A job outlives the owner instance that started it: the Computer keeps
187
194
  // building and its own reports (objects, candidate, status) settle the
188
195
  // job, so an owner restart must not fail it. A job that reaches its expiry
189
- // without a result can never settle, so it fails then instead of reading
196
+ // without a result can never settle, so it is interrupted instead of reading
190
197
  // as running for as long as anyone asks.
191
198
  const failedInstallation = `CASE WHEN installation IS NULL THEN NULL ELSE
192
199
  json_object('kind', 'failed', 'installationId', json_extract(installation, '$.installationId'),
193
200
  'detail', COALESCE(?, 'Build did not succeed')) END`;
194
- const expire = () => owner.sql.exec(`UPDATE oyster_build_jobs SET installation=${failedInstallation}, status='failed', detail=? WHERE status='running' AND expires_at<=?`, "Build expired without a result", "Build expired without a result", now());
201
+ const expire = () => owner.sql.exec(`UPDATE oyster_build_jobs SET installation=${failedInstallation}, status='interrupted', detail=? WHERE status='running' AND expires_at<=?`, "Build expired without a result", "Build expired without a result", now());
195
202
  expire();
196
203
  const decodeRecipe = (recipe) => Schema.decodeUnknownSync(recipeCodec)(recipe);
197
204
  const grantAdmits = async (job, grant) => sameDigest(job.upload_grant_digest, await sha256Hex(new TextEncoder().encode(grant)));
@@ -269,6 +276,9 @@ export function createOysterAppBuilds(options) {
269
276
  return Promise.resolve();
270
277
  },
271
278
  execute: async (input) => {
279
+ // The Computer's connection may close without a terminal execution
280
+ // reply. A missing output is not an exit; a rejected RPC carrying an
281
+ // Error (including an admission refusal) remains a definite failure.
272
282
  const result = await options.computer.port.runBuild({
273
283
  jobId: input.jobId,
274
284
  command: input.command,
@@ -298,10 +308,10 @@ export function createOysterAppBuilds(options) {
298
308
  });
299
309
  }
300
310
  catch (cause) {
301
- // Without a terminal result the build may still be running on the
302
- // Computer; its own reports settle the job, or its expiry fails it.
311
+ // An unknown output can belong to a live builder. Its reports can
312
+ // still succeed; the existing build expiry interrupts it if abandoned.
303
313
  if (!(cause instanceof BuildOutcomeUnknown))
304
- settle(jobId, "failed", cause instanceof Error ? cause.message : "Build failed");
314
+ settle(jobId, "failed", candidateFailureDiagnostic(cause));
305
315
  }
306
316
  return view(required(jobId));
307
317
  };
@@ -75,10 +75,11 @@ export declare function issueBootCapability(input: Readonly<{
75
75
  ui: BootUi;
76
76
  bucket: BootCapabilityBucket;
77
77
  capabilities: BootCapabilityStore;
78
- expiresAt: number;
78
+ now: () => number;
79
79
  }>): Promise<Readonly<{
80
80
  token: string;
81
81
  leaseId: string;
82
+ deadlineAt: number;
82
83
  }>>;
83
84
  /** What one boot page report needs from its host. */
84
85
  export type BootPageReportInput = Readonly<{
@@ -73,11 +73,14 @@ export async function issueBootCapability(input) {
73
73
  throw new BootGateError("ui_mount_failed");
74
74
  byteLengths.push(head.size);
75
75
  }
76
+ // Metadata may take longer than the page budget; start the lease only once
77
+ // every asset has been verified, immediately before issuing it.
78
+ const deadlineAt = input.now() + BOOT_DEADLINE_MS;
76
79
  const lease = {
77
80
  id: bootCapabilityIds.lease.generate(),
78
81
  identity: input.ui.identity,
79
82
  base: input.ui.base,
80
- expiresAt: input.expiresAt,
83
+ expiresAt: deadlineAt + BOOT_TRANSPORT_GRACE_MS,
81
84
  assets: capabilityAssets(input.ui.assets, byteLengths),
82
85
  };
83
86
  validateCapabilityAssetSet(lease);
@@ -87,7 +90,7 @@ export async function issueBootCapability(input) {
87
90
  .replace(/=+$/u, "");
88
91
  const tokenDigest = createHash("sha256").update(token, "utf8").digest("hex");
89
92
  await input.capabilities.issue({ lease, tokenDigest });
90
- return { token, leaseId: lease.id };
93
+ return { token, leaseId: lease.id, deadlineAt };
91
94
  }
92
95
  /**
93
96
  * One UI page check for a Release, for every host, reading no UI bytes:
@@ -102,14 +105,13 @@ export async function reportBootPage(input) {
102
105
  return { kind: "passed" };
103
106
  if (input.check === null || input.origin === null)
104
107
  return { kind: "not_checked", code: "boot_browser_binding_missing" };
105
- const deadlineAt = input.now() + BOOT_DEADLINE_MS;
106
108
  let issued;
107
109
  try {
108
110
  issued = await issueBootCapability({
109
111
  ui,
110
112
  bucket: input.bucket,
111
113
  capabilities: input.capabilities,
112
- expiresAt: deadlineAt + BOOT_TRANSPORT_GRACE_MS,
114
+ now: input.now,
113
115
  });
114
116
  }
115
117
  catch (cause) {
@@ -123,7 +125,7 @@ export async function reportBootPage(input) {
123
125
  origin: input.origin,
124
126
  releaseId: ui.identity.releaseId,
125
127
  capability: { header: BOOT_CAPABILITY_HEADER, token: issued.token },
126
- deadlineAt,
128
+ deadlineAt: issued.deadlineAt,
127
129
  });
128
130
  }
129
131
  finally {
@@ -11,7 +11,7 @@ declare const jobSchema: Schema.Struct<{
11
11
  readonly expiresAt: Schema.Number;
12
12
  readonly remote: Schema.NonEmptyString;
13
13
  readonly reportUrl: Schema.NonEmptyString;
14
- readonly status: Schema.Literals<readonly ["running", "succeeded", "failed", "revoked"]>;
14
+ readonly status: Schema.Literals<readonly ["running", "succeeded", "failed", "interrupted", "revoked"]>;
15
15
  readonly artifact: Schema.NullOr<Schema.Struct<{
16
16
  readonly digest: Schema.NonEmptyString;
17
17
  readonly bytes: Schema.Number;
@@ -87,7 +87,7 @@ export declare function createTeamBuildPublisher(options: Readonly<{
87
87
  readonly expiresAt: number;
88
88
  readonly remote: string;
89
89
  readonly reportUrl: string;
90
- readonly status: "revoked" | "running" | "failed" | "succeeded";
90
+ readonly status: "revoked" | "running" | "failed" | "succeeded" | "interrupted";
91
91
  readonly artifact: {
92
92
  readonly digest: string;
93
93
  readonly bytes: number;
@@ -103,7 +103,7 @@ export declare function createTeamBuildPublisher(options: Readonly<{
103
103
  readonly expiresAt: number;
104
104
  readonly remote: string;
105
105
  readonly reportUrl: string;
106
- readonly status: "revoked" | "running" | "failed" | "succeeded";
106
+ readonly status: "revoked" | "running" | "failed" | "succeeded" | "interrupted";
107
107
  readonly artifact: {
108
108
  readonly digest: string;
109
109
  readonly bytes: number;
@@ -53,6 +53,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
53
53
  import * as Schema from "effect/Schema";
54
54
  import { base64url } from "jose";
55
55
  import { workspaceBuildPolicy } from "./build-policy.js";
56
+ import { boundedDiagnostic } from "@fungi.computer/bird-dog/effect";
56
57
  /** The secret-free Team job; no provider token enters publication. */
57
58
  const jobSchema = Schema.Struct({
58
59
  jobId: Schema.NonEmptyString,
@@ -63,7 +64,13 @@ const jobSchema = Schema.Struct({
63
64
  expiresAt: Schema.Number,
64
65
  remote: Schema.NonEmptyString,
65
66
  reportUrl: Schema.NonEmptyString,
66
- status: Schema.Literals(["running", "succeeded", "failed", "revoked"]),
67
+ status: Schema.Literals([
68
+ "running",
69
+ "succeeded",
70
+ "failed",
71
+ "interrupted",
72
+ "revoked",
73
+ ]),
67
74
  artifact: Schema.NullOr(Schema.Struct({ digest: Schema.NonEmptyString, bytes: Schema.Number })),
68
75
  detail: Schema.NullOr(Schema.String),
69
76
  });
@@ -148,10 +155,8 @@ export function createTeamBuildPublisher(options) {
148
155
  ? "succeeded"
149
156
  : "failed", exitCode === 0
150
157
  ? "Build exited without reporting status"
151
- : "Build exited with " +
152
- String(exitCode) +
153
- ": " +
154
- (result.output?.stderr ?? "").slice(-1_000)));
158
+ : (boundedDiagnostic(result.output?.stderr ?? "") ??
159
+ `Build exited with ${String(exitCode)}`)));
155
160
  await build.deliverProjection();
156
161
  return completed;
157
162
  }
@@ -1,17 +1,21 @@
1
1
  import * as Schema from "effect/Schema";
2
2
  import { releaseSchema } from "@fungi.computer/puffball/effect";
3
3
  import { readFile } from "node:fs/promises";
4
+ import { candidateFailureDiagnostic } from "./candidate-diagnostic.js";
4
5
  import { candidateManifestObjects, teamAppCandidateManifestSchema, } from "../build-contract.js";
5
6
  import { buildTeamCandidate, teamCandidateConfigurationSchema, teamCandidateLayout, TeamCandidateBuildError, } from "./candidate-builder.js";
6
7
  /** Objects in flight at once; each is read from disk only while it uploads. */
7
8
  const uploadConcurrency = 4;
8
9
  /** Attempts per object when its connection drops before a response. */
9
10
  const uploadAttempts = 3;
10
- /** Report a failed build to the host's build output: its kind, never its text. */
11
+ /** Report bounded diagnostics to the host's build output. */
11
12
  function reportFailure(cause) {
13
+ const diagnostic = candidateFailureDiagnostic(cause);
14
+ process.stderr.write(`Team App source/build failed: ${diagnostic}\n`);
12
15
  process.stderr.write(`${JSON.stringify({
13
16
  source: "oyster-candidate-builder",
14
17
  failureKind: cause instanceof Error ? "Error" : "NonErrorThrown",
18
+ diagnostic,
15
19
  })}\n`);
16
20
  }
17
21
  const [configuration, repositoryPath, outputPath] = process.argv.slice(2);
@@ -113,7 +117,5 @@ try {
113
117
  }
114
118
  catch (cause) {
115
119
  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
120
  process.exitCode = 1;
119
121
  }
@@ -14544,6 +14544,21 @@ function makeIsGreaterThanOrEqualTo(options) {
14544
14544
  });
14545
14545
  };
14546
14546
  }
14547
+ function makeIsLessThanOrEqualTo(options) {
14548
+ const lte = isLessThanOrEqualTo(options.order);
14549
+ const formatter = options.formatter ?? format;
14550
+ return (maximum, annotations) => {
14551
+ return makeFilter2((input) => lte(input, maximum), {
14552
+ expected: `a value less than or equal to ${formatter(maximum)}`,
14553
+ arbitraryConstraint: {
14554
+ order: options.order,
14555
+ maximum
14556
+ },
14557
+ ...options.annotate?.(maximum),
14558
+ ...annotations
14559
+ });
14560
+ };
14561
+ }
14547
14562
  function makeIsBetween(deriveOptions) {
14548
14563
  const greaterThanOrEqualTo = isGreaterThanOrEqualTo(deriveOptions.order);
14549
14564
  const greaterThan = isGreaterThan(deriveOptions.order);
@@ -14611,6 +14626,23 @@ var isGreaterThanOrEqualTo2 = /* @__PURE__ */ makeIsGreaterThanOrEqualTo({
14611
14626
  })
14612
14627
  })
14613
14628
  });
14629
+ var isLessThanOrEqualTo2 = /* @__PURE__ */ makeIsLessThanOrEqualTo({
14630
+ order: Number2,
14631
+ annotate: (maximum) => ({
14632
+ representation: {
14633
+ id: "effect/schema/isLessThanOrEqualTo",
14634
+ payload: {
14635
+ maximum: encodeNumberPayload(maximum)
14636
+ }
14637
+ },
14638
+ toJsonSchema: () => ({
14639
+ maximum
14640
+ }),
14641
+ toCode: () => ({
14642
+ runtime: `Schema.isLessThanOrEqualTo(${format(maximum)})`
14643
+ })
14644
+ })
14645
+ });
14614
14646
  var isBetween = /* @__PURE__ */ makeIsBetween({
14615
14647
  order: Number2,
14616
14648
  annotate: (options) => {
@@ -16806,14 +16838,14 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
16806
16838
  return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
16807
16839
  };
16808
16840
  doc.write(`const input = payload.value;`);
16809
- const ids = /* @__PURE__ */ Object.create(null);
16841
+ const ids2 = /* @__PURE__ */ Object.create(null);
16810
16842
  let counter = 0;
16811
16843
  for (const key of normalized.keys) {
16812
- ids[key] = `key_${counter++}`;
16844
+ ids2[key] = `key_${counter++}`;
16813
16845
  }
16814
16846
  doc.write(`const newResult = {};`);
16815
16847
  for (const key of normalized.keys) {
16816
- const id = ids[key];
16848
+ const id = ids2[key];
16817
16849
  const k = esc(key);
16818
16850
  const schema2 = shape[key];
16819
16851
  const isOptionalIn = schema2?._zod?.optin === "optional";
@@ -21625,6 +21657,189 @@ var publicRouteConfigurationSchema = Struct({
21625
21657
  // src/build/candidate-cli.ts
21626
21658
  import { readFile as readFile6 } from "node:fs/promises";
21627
21659
 
21660
+ // ../carrier/dist/internal/post-contract.js
21661
+ var CursorResultSchema = Struct({
21662
+ status: Literals(["accepted", "duplicate"]),
21663
+ cursor: Int.check(isGreaterThanOrEqualTo2(0))
21664
+ });
21665
+ var PostDeliveryResultSchema = Union2([
21666
+ Literals(["accepted", "duplicate", "refused"]),
21667
+ CursorResultSchema,
21668
+ Struct({
21669
+ status: Literals(["accepted", "duplicate", "refused"]),
21670
+ receipt: Json2
21671
+ })
21672
+ ]);
21673
+ var PostError = class extends TaggedError2("PostError") {
21674
+ constructor(message, reason) {
21675
+ super(reason === void 0 ? { message } : { message, reason });
21676
+ }
21677
+ };
21678
+
21679
+ // ../carrier/dist/post.js
21680
+ var Key = String4.check(isMinLength(1), isMaxLength(256));
21681
+ var JobPayload = Struct({ postKey: Key });
21682
+ var PageLimit = Int.check(isGreaterThanOrEqualTo2(1), isLessThanOrEqualTo2(64));
21683
+ var maxRouteDeadlineMs = 7 * 24 * 60 * 60 * 1e3;
21684
+ var ids = defineIdFamily({
21685
+ family: "post",
21686
+ version: 1,
21687
+ kinds: { work: { prefix: "post_work" } }
21688
+ });
21689
+ var PostEnvelopeSchema = Struct({
21690
+ key: Key,
21691
+ kind: Key,
21692
+ address: Unknown2,
21693
+ event: Unknown2
21694
+ });
21695
+ var PostRowSchema = fromJsonString2(Union2([
21696
+ Struct({
21697
+ state: Literal2("pending"),
21698
+ envelope: PostEnvelopeSchema,
21699
+ attempts: Int.check(isGreaterThanOrEqualTo2(0)),
21700
+ nextAttemptAt: Number5.check(isGreaterThanOrEqualTo2(0)),
21701
+ deadlineAt: optional2(Number5.check(isGreaterThanOrEqualTo2(0))),
21702
+ jobId: optional2(Key),
21703
+ delivery: optional2(PostEnvelopeSchema),
21704
+ nextKey: optional2(Key)
21705
+ }),
21706
+ Struct({
21707
+ state: Literal2("delivered"),
21708
+ receipt: optional2(Json2),
21709
+ nextKey: optional2(Key)
21710
+ }),
21711
+ Struct({
21712
+ state: Literal2("dead"),
21713
+ envelope: PostEnvelopeSchema,
21714
+ attempts: Int.check(isGreaterThanOrEqualTo2(0)),
21715
+ reason: Literals(["refused", "exhausted", "expired"]),
21716
+ deadlineAt: optional2(Number5.check(isGreaterThanOrEqualTo2(0))),
21717
+ receipt: optional2(Json2),
21718
+ delivery: optional2(PostEnvelopeSchema),
21719
+ nextKey: optional2(Key)
21720
+ })
21721
+ ]));
21722
+ var StoredRow = Struct({ key: String4, value: String4 });
21723
+ var DueRow = Struct({
21724
+ due: NullOr(Number5.check(isGreaterThanOrEqualTo2(0)))
21725
+ });
21726
+
21727
+ // ../bird-dog/dist/effect.js
21728
+ var Identifier = String4.check(isMinLength(1), isMaxLength(256));
21729
+ var BIRD_DOG_MAIL_CONTENT_MAX_BYTES = 256 * 1024;
21730
+ var MailContent = String4.check(makeFilter2((content) => new TextEncoder().encode(content).byteLength <= BIRD_DOG_MAIL_CONTENT_MAX_BYTES, {
21731
+ message: `Mail content must be at most ${String(BIRD_DOG_MAIL_CONTENT_MAX_BYTES)} UTF-8 bytes`
21732
+ }));
21733
+ var BirdDogAgentReferenceSchema = Struct({
21734
+ kind: Literal2("agent"),
21735
+ agentId: Identifier,
21736
+ sessionId: Identifier
21737
+ });
21738
+ var BirdDogAgentRecipientReferenceSchema = Struct({
21739
+ kind: Literal2("agent"),
21740
+ agentId: Identifier,
21741
+ sessionId: optionalKey2(Identifier)
21742
+ });
21743
+ var BIRD_DOG_DIAGNOSTIC_MAX_LENGTH = 200;
21744
+ var SettledBirdDogReplySchema = Struct({
21745
+ commandId: Identifier,
21746
+ outcome: Literals([
21747
+ "completed",
21748
+ "failed",
21749
+ "interrupted",
21750
+ "cancelled",
21751
+ "outcome_unknown"
21752
+ ]),
21753
+ diagnostic: optionalKey2(String4.check(isMinLength(1), isMaxLength(BIRD_DOG_DIAGNOSTIC_MAX_LENGTH)))
21754
+ });
21755
+ var BirdDogUndeliverableReasonSchema = Literals([
21756
+ "recipient_offline",
21757
+ "recipient_left"
21758
+ ]);
21759
+ var UndeliverableBirdDogReplySchema = Struct({
21760
+ outcome: Literal2("undeliverable"),
21761
+ reason: BirdDogUndeliverableReasonSchema
21762
+ });
21763
+ var AcceptedBirdDogReplySchema = Struct({
21764
+ outcome: Literal2("accepted"),
21765
+ commandId: Identifier
21766
+ });
21767
+ var BirdDogReplySchema = Union2([
21768
+ SettledBirdDogReplySchema,
21769
+ UndeliverableBirdDogReplySchema,
21770
+ AcceptedBirdDogReplySchema
21771
+ ]);
21772
+ var REDACTED = "[redacted]";
21773
+ var schemeCredential = /\b(bearer|basic)\s+[^\s"',;]+/giu;
21774
+ var namedCredential = /\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|authorization)["']?\s*[:=]\s*["']?)(?!(?:bearer|basic)\s)[^\s"',;&]+/giu;
21775
+ var opaqueToken = /[A-Za-z0-9][\w-]{23,}/gu;
21776
+ function redactCredentials(text) {
21777
+ return text.replace(schemeCredential, `$1 ${REDACTED}`).replace(namedCredential, `$1${REDACTED}`).replace(opaqueToken, (token) => /\d/u.test(token) && /[A-Za-z]/u.test(token) ? REDACTED : token);
21778
+ }
21779
+ function boundedDiagnostic(text) {
21780
+ const line = redactCredentials(text.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, " ").trim()).slice(0, BIRD_DOG_DIAGNOSTIC_MAX_LENGTH).trimEnd();
21781
+ return line.length === 0 ? void 0 : line;
21782
+ }
21783
+ var BirdDogMailFields = {
21784
+ key: Identifier,
21785
+ to: BirdDogAgentRecipientReferenceSchema,
21786
+ replyTo: ArraySchema(BirdDogAgentReferenceSchema),
21787
+ content: MailContent,
21788
+ createdAt: Number5.check(isGreaterThanOrEqualTo2(0)),
21789
+ /** The sender's opaque host metadata; BirdDog never reads it. */
21790
+ meta: optionalKey2(Json2)
21791
+ };
21792
+ var BirdDogMailSchema = Union2([
21793
+ Struct({
21794
+ ...BirdDogMailFields,
21795
+ from: BirdDogAgentReferenceSchema,
21796
+ /** Settlement or acceptance facts, present only on a reply. */
21797
+ reply: optionalKey2(BirdDogReplySchema)
21798
+ }),
21799
+ Struct({
21800
+ ...BirdDogMailFields,
21801
+ from: BirdDogAgentRecipientReferenceSchema,
21802
+ /** An unstarted recipient can only author an undeliverable reply. */
21803
+ reply: UndeliverableBirdDogReplySchema
21804
+ })
21805
+ ]);
21806
+ var BirdDogAgentWorkSchema = Struct({
21807
+ jobId: Identifier,
21808
+ sessionId: Identifier,
21809
+ commandFactId: Identifier,
21810
+ recovery: optional2(Struct({
21811
+ maxRecoveries: Int.check(isGreaterThanOrEqualTo2(0))
21812
+ }))
21813
+ });
21814
+ var BirdDogAgentJobPayloadSchema = Struct({
21815
+ work: Struct({
21816
+ sessionId: Identifier,
21817
+ commandFactId: Identifier
21818
+ }),
21819
+ mail: optional2(BirdDogMailSchema)
21820
+ });
21821
+ var BirdDogAgentJobInputSchema = Struct({
21822
+ work: BirdDogAgentWorkSchema,
21823
+ mail: optional2(BirdDogMailSchema)
21824
+ });
21825
+
21826
+ // src/build/candidate-diagnostic.ts
21827
+ function candidateFailureDiagnostic(cause) {
21828
+ const messages = [];
21829
+ let current = cause;
21830
+ for (let depth = 0; depth < 3; depth += 1) {
21831
+ if (!(current instanceof Error)) {
21832
+ if (depth === 0) messages.push("Non-error thrown");
21833
+ break;
21834
+ }
21835
+ messages.push(
21836
+ `${depth === 0 ? "" : "Caused by: "}${current.message || current.name}`
21837
+ );
21838
+ current = current.cause;
21839
+ }
21840
+ return boundedDiagnostic(messages.join("; ")) ?? "Build failed";
21841
+ }
21842
+
21628
21843
  // ../puffball/dist/assets.js
21629
21844
  var positiveInteger = Number5.pipe(check(isInt()), check(isGreaterThan2(0)), check(makeFilter2(Number.isSafeInteger)));
21630
21845
  var assetBuildPolicySchema = Struct({
@@ -36087,10 +36302,14 @@ async function buildTeamCandidate(input) {
36087
36302
  var uploadConcurrency = 4;
36088
36303
  var uploadAttempts = 3;
36089
36304
  function reportFailure2(cause) {
36305
+ const diagnostic = candidateFailureDiagnostic(cause);
36306
+ process.stderr.write(`Team App source/build failed: ${diagnostic}
36307
+ `);
36090
36308
  process.stderr.write(
36091
36309
  `${JSON.stringify({
36092
36310
  source: "oyster-candidate-builder",
36093
- failureKind: cause instanceof Error ? "Error" : "NonErrorThrown"
36311
+ failureKind: cause instanceof Error ? "Error" : "NonErrorThrown",
36312
+ diagnostic
36094
36313
  })}
36095
36314
  `
36096
36315
  );
@@ -36199,7 +36418,6 @@ try {
36199
36418
  );
36200
36419
  } catch (cause) {
36201
36420
  reportFailure2(cause);
36202
- process.stderr.write("Team App source/build failed\n");
36203
36421
  process.exitCode = 1;
36204
36422
  }
36205
36423
  /*! Bundled license information:
@@ -0,0 +1,2 @@
1
+ /** The builder's error and at most two causes, safe for a build record. */
2
+ export declare function candidateFailureDiagnostic(cause: unknown): string;
@@ -0,0 +1,16 @@
1
+ import { boundedDiagnostic } from "@fungi.computer/bird-dog/effect";
2
+ /** The builder's error and at most two causes, safe for a build record. */
3
+ export function candidateFailureDiagnostic(cause) {
4
+ const messages = [];
5
+ let current = cause;
6
+ for (let depth = 0; depth < 3; depth += 1) {
7
+ if (!(current instanceof Error)) {
8
+ if (depth === 0)
9
+ messages.push("Non-error thrown");
10
+ break;
11
+ }
12
+ messages.push(`${depth === 0 ? "" : "Caused by: "}${current.message || current.name}`);
13
+ current = current.cause;
14
+ }
15
+ return boundedDiagnostic(messages.join("; ")) ?? "Build failed";
16
+ }
@@ -20,7 +20,10 @@ export declare class AgentModelRefused extends Error {
20
20
  /** Read the maintained Pi catalog; never accept a model from a different egress. */
21
21
  export declare function selectAgentModel(choice: AgentModelChoice): Promise<Model<Api>>;
22
22
  /** The Agent DO is the only durable owner. Absence is a new-format default, not an old-env reader. */
23
- export declare function agentModelChoice(storage: Pick<DurableObjectStorage, "get" | "put">, admit: (choice: AgentModelChoice) => void): {
23
+ export declare function agentModelChoice(storage: Readonly<{
24
+ get<T = unknown>(key: string): Promise<T | undefined>;
25
+ put(key: string, value: AgentModelChoice): Promise<void>;
26
+ }>, admit: (choice: AgentModelChoice) => void): {
24
27
  read(): Promise<AgentModelChoice>;
25
28
  set(input: AgentModelChoice): Promise<AgentModelChoice>;
26
29
  };
@@ -60,6 +60,13 @@ export type InstalledStarterApp = Readonly<{
60
60
  release: Schema.Json;
61
61
  timings: StarterAppTimings;
62
62
  }>;
63
+ export type StarterAppSetResult = Readonly<{
64
+ installed: readonly InstalledStarterApp[];
65
+ failed: readonly Readonly<{
66
+ installationId: string;
67
+ reason: string;
68
+ }>[];
69
+ }>;
63
70
  /**
64
71
  * Builds the installer runs at once by default. A build's compiler may take a
65
72
  * 4 GB heap, so two fit the platform's 12 GiB Computer (standard-4) with room
@@ -91,4 +98,4 @@ export declare function installStandaloneAppSet(options: Readonly<{
91
98
  /** The idle wait between build status reads; the real timer by default. */
92
99
  idle?: (ms: number, signal: AbortSignal) => Promise<void>;
93
100
  waitMs?: number;
94
- }>): Promise<readonly InstalledStarterApp[]>;
101
+ }>): Promise<StarterAppSetResult>;
@@ -72,7 +72,13 @@ const publishedSchema = Schema.Struct({
72
72
  });
73
73
  const buildSchema = Schema.Struct({
74
74
  jobId: Schema.String,
75
- status: Schema.Literals(["running", "succeeded", "failed", "revoked"]),
75
+ status: Schema.Literals([
76
+ "running",
77
+ "succeeded",
78
+ "failed",
79
+ "interrupted",
80
+ "revoked",
81
+ ]),
76
82
  detail: Schema.NullOr(Schema.String),
77
83
  /** Installed exactly as the host encoded it. */
78
84
  release: Schema.NullOr(Schema.Json),
@@ -271,10 +277,15 @@ export async function installStandaloneAppSet(options) {
271
277
  }
272
278
  };
273
279
  const installed = new Map();
274
- /** Set by the first failure, so no further seed or build starts. */
275
- const stop = { requested: false };
276
- /** Every failure, in the order it happened; the first is the one thrown. */
277
- const failures = [];
280
+ const failures = new Map();
281
+ const fail = (app, cause) => {
282
+ const reason = cause instanceof Error ? cause.message : String(cause);
283
+ failures.set(app.installationId, reason);
284
+ options.report(`Failed ${app.installationId}: ${reason}`);
285
+ };
286
+ const reportFailure = ({ app, cause, }) => {
287
+ fail(app, cause);
288
+ };
278
289
  const seededQueue = [];
279
290
  const waiting = [];
280
291
  const seeding = { finished: false };
@@ -318,21 +329,19 @@ export async function installStandaloneAppSet(options) {
318
329
  const seedAll = async () => {
319
330
  try {
320
331
  for (const app of standaloneAppSet) {
321
- if (stop.requested)
322
- break;
323
- const seeded = await seedApp(app);
324
- const waiter = waiting.shift();
325
- if (waiter === undefined)
326
- seededQueue.push(seeded);
327
- else
328
- waiter(seeded);
332
+ try {
333
+ const seeded = await seedApp(app);
334
+ const waiter = waiting.shift();
335
+ if (waiter === undefined)
336
+ seededQueue.push(seeded);
337
+ else
338
+ waiter(seeded);
339
+ }
340
+ catch (cause) {
341
+ reportFailure({ app, cause });
342
+ }
329
343
  }
330
344
  }
331
- catch (cause) {
332
- stop.requested = true;
333
- failures.push(cause);
334
- throw cause;
335
- }
336
345
  finally {
337
346
  seeding.finished = true;
338
347
  for (const waiter of waiting.splice(0))
@@ -341,7 +350,7 @@ export async function installStandaloneAppSet(options) {
341
350
  };
342
351
  // `concurrency` builds at once, each installing as its build succeeds.
343
352
  const slot = async () => {
344
- for (let next = await takeSeeded(); next !== undefined && !stop.requested; next = await takeSeeded()) {
353
+ for (let next = await takeSeeded(); next !== undefined; next = await takeSeeded()) {
345
354
  const name = next.app.installationId;
346
355
  try {
347
356
  const buildStarted = performance.now();
@@ -416,22 +425,28 @@ export async function installStandaloneAppSet(options) {
416
425
  });
417
426
  }
418
427
  catch (cause) {
419
- stop.requested = true;
420
- failures.push(cause);
421
- throw cause;
428
+ reportFailure({ app: next.app, cause });
422
429
  }
423
430
  }
424
431
  };
425
- // Wait for every seed and build to settle before reporting: nothing of this
426
- // call may still run, or install, after it rejects.
427
- await Promise.allSettled([
432
+ // Wait for every seed and build to settle before returning the full outcome.
433
+ const settled = await Promise.allSettled([
428
434
  seedAll(),
429
435
  ...Array.from({ length: concurrency }, slot),
430
436
  ]);
431
- if (failures.length > 0)
432
- throw failures[0];
433
- return standaloneAppSet.flatMap((app) => {
434
- const result = installed.get(app.installationId);
435
- return result === undefined ? [] : [result];
436
- });
437
+ for (const outcome of settled)
438
+ if (outcome.status === "rejected")
439
+ throw outcome.reason;
440
+ return {
441
+ installed: standaloneAppSet.flatMap((app) => {
442
+ const result = installed.get(app.installationId);
443
+ return result === undefined ? [] : [result];
444
+ }),
445
+ failed: standaloneAppSet.flatMap((app) => {
446
+ const reason = failures.get(app.installationId);
447
+ return reason === undefined
448
+ ? []
449
+ : [{ installationId: app.installationId, reason }];
450
+ }),
451
+ };
437
452
  }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runInstallAppsCli } from "./install-apps-cli.js";
3
+ await runInstallAppsCli();
@@ -1,2 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { installStandaloneAppSet } from "./app-set.js";
3
+ export declare function installApps(args: readonly string[], install?: typeof installStandaloneAppSet): Promise<void>;
4
+ export declare function runInstallAppsCli(args?: readonly string[]): Promise<void>;
@@ -76,7 +76,7 @@ function timingTable(results) {
76
76
  ];
77
77
  return [header, ...rows].map((row) => row.join("\t")).join("\n");
78
78
  }
79
- async function installApps(args) {
79
+ export async function installApps(args, install = installStandaloneAppSet) {
80
80
  const { values } = parseArgs({
81
81
  args: [...args],
82
82
  options: {
@@ -121,7 +121,7 @@ async function installApps(args) {
121
121
  source = { kind: "checkout", directory, revision: `git:${revision}` };
122
122
  }
123
123
  const started = performance.now();
124
- const results = await installStandaloneAppSet({
124
+ const { installed, failed } = await install({
125
125
  source,
126
126
  url,
127
127
  key,
@@ -131,33 +131,43 @@ async function installApps(args) {
131
131
  },
132
132
  });
133
133
  const elapsed = performance.now() - started;
134
- process.stdout.write(`\n${timingTable(results)}\n`);
135
- process.stdout.write(`\nInstalled ${String(results.length)} Apps in ${seconds(elapsed)}. Open ${url}/ to use Tourtois and the installed Apps.\n`);
134
+ process.stdout.write(`\n${timingTable(installed)}\n`);
135
+ process.stdout.write("\nApp\tResult\n");
136
+ for (const app of installed)
137
+ process.stdout.write(`${app.installationId}\tinstalled\n`);
138
+ for (const app of failed)
139
+ process.stdout.write(`${app.installationId}\tfailed: ${app.reason}\n`);
140
+ process.stdout.write(`\nInstalled ${String(installed.length)} Apps; failed ${String(failed.length)} in ${seconds(elapsed)}.${installed.length > 0 ? ` Open ${url}/ to use the installed Apps.` : ""}\n`);
136
141
  if (values.timings !== undefined)
137
142
  await writeFile(values.timings, JSON.stringify({
138
143
  concurrency,
139
144
  elapsedMs: elapsed,
140
- apps: results.map(({ installationId, jobId, timings }) => ({
145
+ apps: installed.map(({ installationId, jobId, timings }) => ({
141
146
  installationId,
142
147
  jobId,
143
148
  timings,
144
149
  })),
150
+ failed,
145
151
  }, null, 2) + "\n");
152
+ if (failed.length > 0)
153
+ process.exitCode = 1;
146
154
  }
147
155
  finally {
148
156
  await rm(scratch, { recursive: true, force: true });
149
157
  }
150
158
  }
151
- const [command, ...rest] = process.argv.slice(2);
152
- if (command === "--help" || command === "-h") {
153
- process.stdout.write(usage + "\n" + installReleaseUsage + "\n");
154
- }
155
- else if (command !== "install-apps" && command !== "install") {
156
- process.stderr.write(`${usage}\n`);
157
- process.exitCode = 2;
159
+ export async function runInstallAppsCli(args = process.argv.slice(2)) {
160
+ const [command, ...rest] = args;
161
+ if (command === "--help" || command === "-h") {
162
+ process.stdout.write(usage + "\n" + installReleaseUsage + "\n");
163
+ }
164
+ else if (command !== "install-apps" && command !== "install") {
165
+ process.stderr.write(`${usage}\n`);
166
+ process.exitCode = 2;
167
+ }
168
+ else
169
+ await (command === "install" ? installReleaseCommand(rest) : installApps(rest)).catch((cause) => {
170
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
171
+ process.exitCode = 1;
172
+ });
158
173
  }
159
- else
160
- await (command === "install" ? installReleaseCommand(rest) : installApps(rest)).catch((cause) => {
161
- process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
162
- process.exitCode = 1;
163
- });
@@ -187,7 +187,7 @@ export declare abstract class StandaloneRegistry extends OysterRegistry implemen
187
187
  appId: string;
188
188
  releaseId: string;
189
189
  sourceRevision: string;
190
- status: "running" | "succeeded" | "failed" | "revoked";
190
+ status: "running" | "succeeded" | "failed" | "interrupted" | "revoked";
191
191
  detail: string | null;
192
192
  release: Release | null;
193
193
  installation: import("../app-builds.js").OysterBuildInstallation | null;
package/dist/workers.js CHANGED
@@ -48,6 +48,8 @@ import { createOysterRootData } from "./root-data.js";
48
48
  import { backendCache, resolveBackend } from "./release.js";
49
49
  import { ensureAppSourceTables, createOysterAppSourceReceipts, createOysterAppRegistrations, createOysterArtifactsRepositoryMetadataStore, } from "./app-sources.js";
50
50
  export { r2ArtifactStore, r2FileSource, ownerKeyAuth };
51
+ /** Issuer-ahead clock allowance at issue only; resolution never adds skew. */
52
+ const BOOT_ISSUER_SKEW_MS = 2_000;
51
53
  const recordRow = Schema.Struct({
52
54
  installation_id: Schema.String,
53
55
  record: Schema.String,
@@ -433,8 +435,14 @@ export class OysterRegistry extends DurableObject {
433
435
  const { lease, tokenDigest } = bootCapabilityGrantSchema.parse(input.grant);
434
436
  validateCapabilityAssetSet(lease);
435
437
  const now = Date.now();
438
+ // The issuer can be at most 2 s ahead of this registry. Apply that
439
+ // allowance only on issue: resolution still uses the stored absolute
440
+ // expiry, and revocation still removes the token after the page check.
441
+ // A lease can therefore live at most 14 s on this clock, not be renewed
442
+ // or replayed once expired.
436
443
  if (lease.expiresAt <= now ||
437
- lease.expiresAt > now + BOOT_DEADLINE_MS + BOOT_TRANSPORT_GRACE_MS)
444
+ lease.expiresAt >
445
+ now + BOOT_DEADLINE_MS + BOOT_TRANSPORT_GRACE_MS + BOOT_ISSUER_SKEW_MS)
438
446
  throw new Error("Boot capability lease is outside its deadline");
439
447
  this.ctx.storage.sql.exec("DELETE FROM boot_capabilities WHERE expires_at <= ?", now);
440
448
  const count = this.ctx.storage.sql
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@fungi.computer/oyster",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Run your own Apps on Cloudflare Workers or celld: installable App Releases, each with its own Durable Object, SQLite database and declared bindings",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
9
  "bin": {
10
- "oyster": "./dist/standalone/install-apps-cli.js"
10
+ "oyster": "./dist/standalone/install-apps-bin.js"
11
11
  },
12
12
  "files": [
13
13
  "dist",