@fungi.computer/oyster 0.2.0 → 0.3.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.
- package/README.md +396 -72
- package/dist/agents.d.ts +3 -2
- package/dist/agents.js +4 -2
- package/dist/app-builds.d.ts +81 -8
- package/dist/app-builds.js +213 -60
- package/dist/app-storage.d.ts +3 -3
- package/dist/approval-capability.d.ts +5 -0
- package/dist/approval-capability.js +46 -0
- package/dist/approval-home.d.ts +51 -0
- package/dist/approval-home.js +226 -0
- package/dist/apps/catalog.d.ts +335 -0
- package/dist/apps/catalog.js +46 -0
- package/dist/apps/gate.d.ts +20 -6
- package/dist/apps/gate.js +42 -16
- package/dist/apps/index.d.ts +3 -1
- package/dist/apps/index.js +3 -1
- package/dist/apps/installation.d.ts +132 -51
- package/dist/apps/installation.js +176 -70
- package/dist/apps/permissions.d.ts +247 -0
- package/dist/apps/permissions.js +255 -0
- package/dist/boot-capability-route.d.ts +15 -0
- package/dist/boot-capability-route.js +196 -0
- package/dist/boot-capability.d.ts +103 -0
- package/dist/boot-capability.js +141 -0
- package/dist/boot-construction.d.ts +12 -0
- package/dist/boot-construction.js +175 -0
- package/dist/boot-gate.d.ts +267 -0
- package/dist/boot-gate.js +236 -0
- package/dist/boot-host-script.d.ts +2 -0
- package/dist/boot-host-script.js +1 -0
- package/dist/boot-mount.d.ts +21 -0
- package/dist/boot-mount.js +65 -0
- package/dist/browser/boot.d.ts +73 -0
- package/dist/browser/boot.js +502 -0
- package/dist/browser/browser.d.ts +104 -0
- package/dist/browser/browser.js +374 -0
- package/dist/browser/capability-source.d.ts +76 -0
- package/dist/browser/capability-source.js +338 -0
- package/dist/browser/cdp-client.d.ts +56 -0
- package/dist/browser/cdp-client.js +184 -0
- package/dist/browser/errors.d.ts +79 -0
- package/dist/browser/errors.js +32 -0
- package/dist/browser/index.d.ts +16 -0
- package/dist/browser/index.js +14 -0
- package/dist/browser/lease-registry.d.ts +61 -0
- package/dist/browser/lease-registry.js +101 -0
- package/dist/browser/operations.d.ts +225 -0
- package/dist/browser/operations.js +137 -0
- package/dist/browser/page.d.ts +25 -0
- package/dist/browser/page.js +172 -0
- package/dist/browser/quick-reads.d.ts +29 -0
- package/dist/browser/quick-reads.js +160 -0
- package/dist/browser/sessions.d.ts +52 -0
- package/dist/browser/sessions.js +118 -0
- package/dist/browser/url-policy.d.ts +18 -0
- package/dist/browser/url-policy.js +58 -0
- package/dist/build/app-imports.d.ts +12 -0
- package/dist/build/app-imports.js +105 -0
- package/dist/build/build-policy.d.ts +5 -3
- package/dist/build/build-policy.js +5 -3
- package/dist/build/candidate-builder.d.ts +1 -1
- package/dist/build/candidate-cli.mjs +8375 -334
- package/dist/build/local-build.d.ts +2 -0
- package/dist/build/local-build.js +28 -6
- package/dist/build/snapshot-package-store.d.ts +1 -1
- package/dist/build/snapshot-package-store.js +7 -2
- package/dist/build/workspace-build.d.ts +92 -5
- package/dist/build/workspace-build.js +93 -19
- package/dist/build.d.ts +1 -1
- package/dist/build.js +1 -1
- package/dist/catalog-frame.js +1 -1
- package/dist/cloudflare.d.ts +13 -3
- package/dist/cloudflare.js +102 -4
- package/dist/frame-binding-wire-script.js +1 -1
- package/dist/frame-binding-wire.js +4 -2
- package/dist/git-auth.d.ts +0 -2
- package/dist/git-auth.js +0 -2
- package/dist/git.d.ts +0 -1
- package/dist/git.js +0 -2
- package/dist/host.d.ts +39 -13
- package/dist/host.js +268 -142
- package/dist/index.d.ts +25 -15
- package/dist/index.js +39 -16
- package/dist/install-release-cli.d.ts +2 -0
- package/dist/install-release-cli.js +46 -0
- package/dist/installation-client.d.ts +43 -0
- package/dist/installation-client.js +178 -0
- package/dist/installation-execution.d.ts +11 -0
- package/dist/installation-execution.js +184 -0
- package/dist/installation-operations.d.ts +1437 -0
- package/dist/installation-operations.js +664 -0
- package/dist/installation-store-generation.d.ts +3 -0
- package/dist/installation-store-generation.js +16 -0
- package/dist/instance-id.d.ts +10 -0
- package/dist/instance-id.js +10 -0
- package/dist/instance-refusal.d.ts +11 -0
- package/dist/instance-refusal.js +21 -0
- package/dist/internal/delivery.js +4 -2
- package/dist/internal/host-gate.js +7 -1
- package/dist/internal/registry-installations.js +4 -4
- package/dist/keys.d.ts +5 -1
- package/dist/keys.js +10 -2
- package/dist/machine-link/authority.d.ts +89 -0
- package/dist/machine-link/authority.js +37 -0
- package/dist/machine-link/client.d.ts +66 -0
- package/dist/machine-link/client.js +94 -0
- package/dist/machine-link/delivery.d.ts +18 -0
- package/dist/machine-link/delivery.js +55 -0
- package/dist/machine-link/enrollment.d.ts +111 -0
- package/dist/machine-link/enrollment.js +286 -0
- package/dist/machine-link/fences.d.ts +37 -0
- package/dist/machine-link/fences.js +166 -0
- package/dist/machine-link/home.d.ts +20 -0
- package/dist/machine-link/home.js +24 -0
- package/dist/machine-link/host.d.ts +43 -0
- package/dist/machine-link/host.js +189 -0
- package/dist/machine-link/index.d.ts +15 -0
- package/dist/machine-link/index.js +15 -0
- package/dist/machine-link/link.d.ts +48 -0
- package/dist/machine-link/link.js +223 -0
- package/dist/machine-link/presence.d.ts +10 -0
- package/dist/machine-link/presence.js +25 -0
- package/dist/machine-link/workers.d.ts +6 -0
- package/dist/machine-link/workers.js +6 -0
- package/dist/mcp-owner.d.ts +2 -2
- package/dist/mcp-owner.js +41 -33
- package/dist/mcp.d.ts +15 -3
- package/dist/mcp.js +146 -25
- package/dist/older-oyster-store-refusal.d.ts +4 -0
- package/dist/older-oyster-store-refusal.js +18 -0
- package/dist/older-oyster-store.d.ts +21 -0
- package/dist/older-oyster-store.js +43 -0
- package/dist/pack.d.ts +14 -3
- package/dist/pack.js +44 -19
- package/dist/page/app-access.d.ts +7 -0
- package/dist/page/app-access.js +75 -0
- package/dist/page/boot-host.js +57 -0
- package/dist/page/capabilities.d.ts +3 -3
- package/dist/page/capabilities.js +16 -12
- package/dist/page/computer-handles.d.ts +3 -3
- package/dist/page/computer-target.d.ts +3 -0
- package/dist/page/computer-target.js +9 -0
- package/dist/page/mount.d.ts +23 -3
- package/dist/page/mount.js +152 -16
- package/dist/page-assets.d.ts +22 -0
- package/dist/page-assets.js +28 -0
- package/dist/page-script.d.ts +3 -0
- package/dist/page-script.js +5 -4
- package/dist/release.d.ts +13 -6
- package/dist/release.js +10 -7
- package/dist/root-data.d.ts +16 -6
- package/dist/root-data.js +2 -2
- package/dist/source/index.d.ts +1 -1
- package/dist/source/team-source.d.ts +20 -8
- package/dist/source/team-source.js +39 -10
- package/dist/source/template-seed.d.ts +2 -2
- package/dist/standalone/agent-mail.d.ts +24 -0
- package/dist/standalone/agent-mail.js +62 -0
- package/dist/standalone/agent-store.d.ts +9 -0
- package/dist/standalone/agent-store.js +30 -0
- package/dist/standalone/agents.d.ts +68 -9
- package/dist/standalone/agents.js +428 -56
- package/dist/standalone/app-builds-binding.d.ts +20 -2
- package/dist/standalone/app-builds-binding.js +147 -23
- package/dist/standalone/app-set.d.ts +5 -2
- package/dist/standalone/app-set.js +186 -144
- package/dist/standalone/browser.d.ts +11 -0
- package/dist/standalone/browser.js +72 -0
- package/dist/standalone/builds.d.ts +13 -4
- package/dist/standalone/builds.js +28 -44
- package/dist/standalone/computer-egress.d.ts +2 -0
- package/dist/standalone/computer-egress.js +13 -4
- package/dist/standalone/computer-fleet.d.ts +43 -0
- package/dist/standalone/computer-fleet.js +200 -0
- package/dist/standalone/computer.d.ts +44 -9
- package/dist/standalone/computer.js +129 -22
- package/dist/standalone/install-apps-cli.js +43 -6
- package/dist/standalone/instance.d.ts +2 -2
- package/dist/standalone/instance.js +8 -11
- package/dist/standalone/machine-link.d.ts +354 -0
- package/dist/standalone/machine-link.js +368 -0
- package/dist/standalone/media.d.ts +2 -2
- package/dist/standalone/media.js +19 -16
- package/dist/standalone/preview.d.ts +2 -2
- package/dist/standalone/preview.js +37 -10
- package/dist/standalone/registry.d.ts +151 -9
- package/dist/standalone/registry.js +305 -8
- package/dist/standalone/worker.d.ts +19 -5
- package/dist/standalone/worker.js +90 -54
- package/dist/standalone/workflow-binding.d.ts +18 -22
- package/dist/standalone/workflow-binding.js +18 -18
- package/dist/standalone/workflow-control.d.ts +4 -0
- package/dist/standalone/workflow-control.js +40 -0
- package/dist/standalone/workflow-events.d.ts +2 -2
- package/dist/standalone/workflow-events.js +9 -10
- package/dist/standalone/workflow-git-capture.js +4 -3
- package/dist/standalone/workflow-host.d.ts +162 -161
- package/dist/standalone/workflow-host.js +101 -21
- package/dist/standalone/workflow-node-capture.js +11 -4
- package/dist/standalone/workflow-schedules.d.ts +5 -2
- package/dist/standalone/workflow-schedules.js +9 -10
- package/dist/standalone/workflow-trigger-host.d.ts +65 -5
- package/dist/standalone/workflow-trigger-host.js +2 -2
- package/dist/standalone/workflow-webhooks.d.ts +22 -0
- package/dist/standalone/workflow-webhooks.js +39 -0
- package/dist/standalone/workflows.d.ts +140 -43
- package/dist/standalone/workflows.js +198 -39
- package/dist/storage-frame.d.ts +6 -6
- package/dist/testing/capabilities.js +14 -3
- package/dist/testing/installation.js +26 -2
- package/dist/testing/worker.js +1 -1
- package/dist/testing/workers-runtime.d.ts +8 -2
- package/dist/testing/workers-runtime.js +20 -2
- package/dist/webhook-abuse.d.ts +25 -0
- package/dist/webhook-abuse.js +54 -0
- package/dist/webhook-custody.d.ts +144 -0
- package/dist/webhook-custody.js +222 -0
- package/dist/webhook-ingress.d.ts +36 -0
- package/dist/webhook-ingress.js +146 -0
- package/dist/webhooks.d.ts +111 -0
- package/dist/webhooks.js +189 -0
- package/dist/workers-git.d.ts +12 -17
- package/dist/workers-git.js +64 -52
- package/dist/workers.d.ts +226 -13
- package/dist/workers.js +363 -51
- package/dist/workflow-agent-authority.d.ts +8 -0
- package/dist/workflow-agent-authority.js +42 -0
- package/dist/workflows.d.ts +66 -0
- package/dist/workflows.js +97 -0
- package/docs/api/README.md +63 -37
- package/docs/api/agents.md +17 -0
- package/docs/api/app-storage.md +1 -1
- package/docs/api/apps.md +1394 -543
- package/docs/api/browser/boot.md +132 -0
- package/docs/api/browser.md +1440 -0
- package/docs/api/build-contract.md +38 -25
- package/docs/api/build.md +263 -84
- package/docs/api/cloudflare.md +93 -7
- package/docs/api/git.md +3 -13
- package/docs/api/host.md +944 -118
- package/docs/api/index.md +320 -18
- package/docs/api/keys.md +24 -1
- package/docs/api/mcp.md +59 -12
- package/docs/api/modules.md +33 -40
- package/docs/api/pack.md +57 -8
- package/docs/api/page/mount.md +92 -4
- package/docs/api/workers-git.md +103 -24
- package/docs/api/workers.md +1594 -244
- package/docs/site/agent-quickstart.mdx +28 -14
- package/examples/standalone/worker.ts +3 -1
- package/examples/standalone/wrangler.jsonc +2 -0
- package/package.json +73 -71
- package/dist/installation-consent.d.ts +0 -81
- package/dist/installation-consent.js +0 -84
- package/dist/shell/app-directory.d.ts +0 -542
- package/dist/shell/app-directory.js +0 -196
- package/dist/shell/browser-actions.d.ts +0 -7
- package/dist/shell/browser-actions.js +0 -56
- package/dist/shell/command-host.d.ts +0 -112
- package/dist/shell/command-host.js +0 -502
- package/dist/shell/commands.d.ts +0 -25
- package/dist/shell/commands.js +0 -78
- package/dist/shell/index.d.ts +0 -8
- package/dist/shell/index.js +0 -8
- package/dist/shell/keys.d.ts +0 -41
- package/dist/shell/keys.js +0 -134
- package/dist/shell/shell-module.d.ts +0 -55
- package/dist/shell/shell-module.js +0 -86
- package/dist/shell/window-manager.d.ts +0 -128
- package/dist/shell/window-manager.js +0 -730
- package/dist/shell/workbench-contract.d.ts +0 -68
- package/dist/shell/workbench-directory.d.ts +0 -332
- package/dist/shell/workbench-directory.js +0 -35
- package/dist/shell/workbench-host-types.d.ts +0 -96
- package/dist/shell/workbench-host-types.js +0 -1
- package/dist/shell/workbench-host.d.ts +0 -34
- package/dist/shell/workbench-host.js +0 -352
- package/dist/shell/workbench-model.d.ts +0 -194
- package/dist/shell/workbench-model.js +0 -53
- package/dist/shell/workbench.d.ts +0 -4
- package/dist/shell/workbench.js +0 -2
- package/docs/api/shell/app-directory.md +0 -599
- package/docs/api/shell/command-host.md +0 -224
- package/docs/api/shell/commands.md +0 -197
- package/docs/api/shell/keys.md +0 -183
- package/docs/api/shell/shell-module.md +0 -204
- package/docs/api/shell/window-manager.md +0 -267
- package/docs/api/shell/workbench-directory.md +0 -119
- package/docs/api/shell/workbench.md +0 -205
- package/docs/api/shell.md +0 -1915
- /package/dist/{shell/workbench-contract.js → page/boot-host.d.ts} +0 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineIdFamily } from "@fungi.computer/cairn/id";
|
|
4
|
+
import { Watchdog, } from "@fungi.computer/watchdog";
|
|
5
|
+
import { releaseSchema } from "@fungi.computer/puffball/effect";
|
|
6
|
+
import { hostActorSchema } from "@fungi.computer/puffball/app-method-runtime";
|
|
7
|
+
import { installationIdSchema, installationSchema, isCommittedInstallation, ownerCodec, } from "./apps/installation.js";
|
|
8
|
+
import { instanceIds, instanceIdSchema } from "./instance-id.js";
|
|
9
|
+
import { tenantIdSchema, tenantScope } from "./tenant.js";
|
|
10
|
+
import { uiBootCheckOutcomeSchema, } from "./boot-gate.js";
|
|
11
|
+
// Request identity has Watchdog's public job-key bound; it is not a retry cap.
|
|
12
|
+
export const installationRequestIdSchema = z.string().min(1).max(256);
|
|
13
|
+
const scopeSchema = z.strictObject({
|
|
14
|
+
tenantId: ownerCodec(tenantIdSchema),
|
|
15
|
+
origin: z.url(),
|
|
16
|
+
});
|
|
17
|
+
const releaseCodec = ownerCodec(releaseSchema);
|
|
18
|
+
const identity = {
|
|
19
|
+
requestId: installationRequestIdSchema,
|
|
20
|
+
requestedInstallationId: installationIdSchema,
|
|
21
|
+
installationId: installationIdSchema,
|
|
22
|
+
release: releaseCodec,
|
|
23
|
+
};
|
|
24
|
+
const creationSchema = z.enum(["fresh", "replacement", "reminted"]);
|
|
25
|
+
const refusalSchema = z.strictObject({
|
|
26
|
+
state: z.literal("refused"),
|
|
27
|
+
status: z.number().int().min(400).max(599),
|
|
28
|
+
code: z.string().min(1).max(200),
|
|
29
|
+
});
|
|
30
|
+
const successSchema = z.strictObject({
|
|
31
|
+
state: z.literal("succeeded"),
|
|
32
|
+
installation: installationSchema,
|
|
33
|
+
creation: creationSchema,
|
|
34
|
+
uiCheck: uiBootCheckOutcomeSchema.nullable(),
|
|
35
|
+
});
|
|
36
|
+
const unconfirmedSchema = z.strictObject({
|
|
37
|
+
state: z.literal("unconfirmed"),
|
|
38
|
+
outcome: z.enum(["outcome_unknown", "interrupted", "cancelled"]),
|
|
39
|
+
});
|
|
40
|
+
/** Application evidence, never a classification of an edge's HTTP response. */
|
|
41
|
+
const installationOutcomeSchema = z.discriminatedUnion("state", [
|
|
42
|
+
successSchema,
|
|
43
|
+
refusalSchema,
|
|
44
|
+
unconfirmedSchema,
|
|
45
|
+
]);
|
|
46
|
+
const pendingOperationSchema = z.strictObject({
|
|
47
|
+
...identity,
|
|
48
|
+
state: z.literal("pending"),
|
|
49
|
+
});
|
|
50
|
+
const successReferenceSchema = z.strictObject({
|
|
51
|
+
...identity,
|
|
52
|
+
state: z.literal("succeeded"),
|
|
53
|
+
reference: z.strictObject({
|
|
54
|
+
generation: z.string().min(1),
|
|
55
|
+
instanceId: ownerCodec(instanceIdSchema),
|
|
56
|
+
authorityVersion: z.number().int().positive(),
|
|
57
|
+
status: z.enum(["enabled", "disabled", "deleted"]),
|
|
58
|
+
}),
|
|
59
|
+
creation: creationSchema,
|
|
60
|
+
uiCheck: uiBootCheckOutcomeSchema.nullable(),
|
|
61
|
+
});
|
|
62
|
+
function matchesReference(operation, installation) {
|
|
63
|
+
return isCommittedInstallation(installation, {
|
|
64
|
+
owner: { kind: "installation", id: operation.installationId },
|
|
65
|
+
instanceId: operation.reference.instanceId,
|
|
66
|
+
appId: operation.release.appId,
|
|
67
|
+
releaseId: operation.release.releaseId,
|
|
68
|
+
authorityVersion: operation.reference.authorityVersion,
|
|
69
|
+
status: operation.reference.status,
|
|
70
|
+
}, operation.release);
|
|
71
|
+
}
|
|
72
|
+
const terminalOperationSchema = z.discriminatedUnion("state", [
|
|
73
|
+
successReferenceSchema,
|
|
74
|
+
refusalSchema.extend(identity),
|
|
75
|
+
unconfirmedSchema.extend(identity),
|
|
76
|
+
]);
|
|
77
|
+
/** One wire contract for admission and subsequent authoritative observation. */
|
|
78
|
+
export const installationOperationSchema = z.discriminatedUnion("state", [
|
|
79
|
+
pendingOperationSchema,
|
|
80
|
+
successReferenceSchema
|
|
81
|
+
.extend({ installation: installationSchema.nullable() })
|
|
82
|
+
.refine((operation) => operation.installation === null ||
|
|
83
|
+
matchesReference(operation, operation.installation), "Installation does not match its committed reference"),
|
|
84
|
+
refusalSchema.extend(identity),
|
|
85
|
+
unconfirmedSchema.extend(identity),
|
|
86
|
+
]);
|
|
87
|
+
const installationAdmissionSchema = z.strictObject({
|
|
88
|
+
requestId: installationRequestIdSchema,
|
|
89
|
+
installationId: installationIdSchema,
|
|
90
|
+
release: releaseCodec,
|
|
91
|
+
actor: hostActorSchema,
|
|
92
|
+
intent: z.enum(["install-or-select", "install-or-remint", "create-only"]),
|
|
93
|
+
// A Workflow may reuse the owner's approval, never grant new authority.
|
|
94
|
+
origin: z.enum(["owner-direct", "workflow"]),
|
|
95
|
+
});
|
|
96
|
+
const planSchema = z
|
|
97
|
+
.strictObject({
|
|
98
|
+
installation: installationSchema,
|
|
99
|
+
hasBackend: z.boolean(),
|
|
100
|
+
prepared: z.boolean(),
|
|
101
|
+
uiCheck: uiBootCheckOutcomeSchema.nullable().optional(),
|
|
102
|
+
})
|
|
103
|
+
.refine((plan) => !plan.prepared ||
|
|
104
|
+
(plan.uiCheck !== undefined &&
|
|
105
|
+
(plan.installation.status !== "enabled" || plan.uiCheck !== null)));
|
|
106
|
+
/** Retained intent and exact baseline; recovery never re-reduces a newer row. */
|
|
107
|
+
export const retainedInstallationCommandSchema = z.strictObject({
|
|
108
|
+
admission: installationAdmissionSchema,
|
|
109
|
+
scope: scopeSchema,
|
|
110
|
+
installationId: installationIdSchema,
|
|
111
|
+
instanceId: ownerCodec(instanceIdSchema),
|
|
112
|
+
generation: z.string().min(1),
|
|
113
|
+
baseline: installationSchema.nullable(),
|
|
114
|
+
acceptedAt: z.number().int().nonnegative(),
|
|
115
|
+
creation: creationSchema,
|
|
116
|
+
plan: planSchema.nullable(),
|
|
117
|
+
operation: pendingOperationSchema,
|
|
118
|
+
});
|
|
119
|
+
const settledInstallationCommandSchema = z.strictObject({
|
|
120
|
+
kind: z.literal("settled"),
|
|
121
|
+
inputDigest: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
122
|
+
scope: scopeSchema,
|
|
123
|
+
generation: z.string().min(1),
|
|
124
|
+
acceptedAt: z.number().int().nonnegative(),
|
|
125
|
+
settledAt: z.number().int().nonnegative(),
|
|
126
|
+
operation: terminalOperationSchema,
|
|
127
|
+
});
|
|
128
|
+
const storedCommandSchema = z.union([
|
|
129
|
+
retainedInstallationCommandSchema,
|
|
130
|
+
settledInstallationCommandSchema,
|
|
131
|
+
]);
|
|
132
|
+
const inputDigest = (input) => createHash("sha256")
|
|
133
|
+
.update(JSON.stringify(installationAdmissionSchema.parse(input)))
|
|
134
|
+
.digest("hex");
|
|
135
|
+
const installationOperationRequestSchema = z.discriminatedUnion("action", [
|
|
136
|
+
z.strictObject({
|
|
137
|
+
action: z.literal("admit"),
|
|
138
|
+
scope: scopeSchema,
|
|
139
|
+
input: installationAdmissionSchema,
|
|
140
|
+
}),
|
|
141
|
+
z.strictObject({
|
|
142
|
+
action: z.literal("read"),
|
|
143
|
+
scope: scopeSchema,
|
|
144
|
+
requestId: installationRequestIdSchema,
|
|
145
|
+
installationId: installationIdSchema,
|
|
146
|
+
}),
|
|
147
|
+
z.strictObject({
|
|
148
|
+
action: z.literal("load"),
|
|
149
|
+
scope: scopeSchema,
|
|
150
|
+
requestId: installationRequestIdSchema,
|
|
151
|
+
}),
|
|
152
|
+
z.strictObject({
|
|
153
|
+
action: z.literal("plan"),
|
|
154
|
+
scope: scopeSchema,
|
|
155
|
+
requestId: installationRequestIdSchema,
|
|
156
|
+
installation: installationSchema,
|
|
157
|
+
hasBackend: z.boolean(),
|
|
158
|
+
}),
|
|
159
|
+
z.strictObject({
|
|
160
|
+
action: z.literal("commit"),
|
|
161
|
+
scope: scopeSchema,
|
|
162
|
+
requestId: installationRequestIdSchema,
|
|
163
|
+
uiCheck: uiBootCheckOutcomeSchema.nullable(),
|
|
164
|
+
}),
|
|
165
|
+
]);
|
|
166
|
+
const installationOperationReplySchema = z.discriminatedUnion("kind", [
|
|
167
|
+
z.strictObject({
|
|
168
|
+
kind: z.literal("refused"),
|
|
169
|
+
code: z.enum([
|
|
170
|
+
"request_conflict",
|
|
171
|
+
"stale_authority",
|
|
172
|
+
"needs_consent",
|
|
173
|
+
"unavailable",
|
|
174
|
+
"stored_operation_invalid",
|
|
175
|
+
]),
|
|
176
|
+
}),
|
|
177
|
+
z.strictObject({
|
|
178
|
+
kind: z.literal("operation"),
|
|
179
|
+
operation: installationOperationSchema.nullable(),
|
|
180
|
+
}),
|
|
181
|
+
z.strictObject({
|
|
182
|
+
kind: z.literal("command"),
|
|
183
|
+
command: retainedInstallationCommandSchema.nullable(),
|
|
184
|
+
}),
|
|
185
|
+
z.strictObject({
|
|
186
|
+
kind: z.literal("commit"),
|
|
187
|
+
commit: z.discriminatedUnion("ok", [
|
|
188
|
+
z.strictObject({ ok: z.literal(true) }),
|
|
189
|
+
z.strictObject({
|
|
190
|
+
ok: z.literal(false),
|
|
191
|
+
code: z.literal("stale_authority"),
|
|
192
|
+
}),
|
|
193
|
+
]),
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
196
|
+
export class InstallationOperationError extends Error {
|
|
197
|
+
code;
|
|
198
|
+
constructor(code) {
|
|
199
|
+
super(code === "stored_operation_invalid"
|
|
200
|
+
? "Stored installation operation is incompatible or invalid; republish this standalone preview."
|
|
201
|
+
: code);
|
|
202
|
+
this.code = code;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Decode ordinary RPC replies and rehydrate only the owner's closed refusal. */
|
|
206
|
+
export function installationOperationsPort(send, scope) {
|
|
207
|
+
const call = async (request) => {
|
|
208
|
+
const reply = installationOperationReplySchema.parse(await send(request));
|
|
209
|
+
if (reply.kind === "refused")
|
|
210
|
+
throw new InstallationOperationError(reply.code);
|
|
211
|
+
return reply;
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
async admit(input) {
|
|
215
|
+
const reply = await call({ action: "admit", scope, input });
|
|
216
|
+
if (reply.kind !== "operation" || reply.operation === null)
|
|
217
|
+
throw new InstallationOperationError("unavailable");
|
|
218
|
+
return reply.operation;
|
|
219
|
+
},
|
|
220
|
+
async read(requestId, installationId) {
|
|
221
|
+
const reply = await call({
|
|
222
|
+
action: "read",
|
|
223
|
+
scope,
|
|
224
|
+
requestId,
|
|
225
|
+
installationId,
|
|
226
|
+
});
|
|
227
|
+
if (reply.kind !== "operation")
|
|
228
|
+
throw new InstallationOperationError("unavailable");
|
|
229
|
+
return reply.operation;
|
|
230
|
+
},
|
|
231
|
+
async load(requestId) {
|
|
232
|
+
const reply = await call({ action: "load", scope, requestId });
|
|
233
|
+
if (reply.kind !== "command")
|
|
234
|
+
throw new InstallationOperationError("unavailable");
|
|
235
|
+
return reply.command;
|
|
236
|
+
},
|
|
237
|
+
async plan(requestId, installation, hasBackend) {
|
|
238
|
+
const reply = await call({
|
|
239
|
+
action: "plan",
|
|
240
|
+
scope,
|
|
241
|
+
requestId,
|
|
242
|
+
installation,
|
|
243
|
+
hasBackend,
|
|
244
|
+
});
|
|
245
|
+
if (reply.kind !== "command" || reply.command === null)
|
|
246
|
+
throw new InstallationOperationError("unavailable");
|
|
247
|
+
return reply.command;
|
|
248
|
+
},
|
|
249
|
+
async commit(requestId, uiCheck) {
|
|
250
|
+
const reply = await call({ action: "commit", scope, requestId, uiCheck });
|
|
251
|
+
if (reply.kind !== "commit")
|
|
252
|
+
throw new InstallationOperationError("unavailable");
|
|
253
|
+
return reply.commit;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
export const installationCommandIds = defineIdFamily({
|
|
258
|
+
family: "app-installation",
|
|
259
|
+
version: 1,
|
|
260
|
+
kinds: {
|
|
261
|
+
installation: { prefix: "installation" },
|
|
262
|
+
request: { prefix: "installreq" },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const storedRow = z.strictObject({
|
|
266
|
+
command: z.string(),
|
|
267
|
+
provisional_outcome: z.string().nullable(),
|
|
268
|
+
});
|
|
269
|
+
const settledRetentionMs = 90 * 24 * 60 * 60 * 1_000;
|
|
270
|
+
// A maintenance quantum, never an admission quota or lifetime history limit.
|
|
271
|
+
const retentionBatchSize = 16;
|
|
272
|
+
const retentionCandidateRow = z.strictObject({
|
|
273
|
+
request_id: installationRequestIdSchema,
|
|
274
|
+
installation_id: installationIdSchema,
|
|
275
|
+
settled_at: z.number().int().nonnegative(),
|
|
276
|
+
retention_due_at: z.number().int().nonnegative(),
|
|
277
|
+
provisional_outcome: z.string().nullable(),
|
|
278
|
+
});
|
|
279
|
+
const retentionWakeRow = z.strictObject({
|
|
280
|
+
due: z.number().int().nonnegative().nullable(),
|
|
281
|
+
});
|
|
282
|
+
/** Current contract only: obsolete preview data is refused, never translated. */
|
|
283
|
+
function readStored(schema, encoded) {
|
|
284
|
+
try {
|
|
285
|
+
return schema.parse(JSON.parse(encoded));
|
|
286
|
+
}
|
|
287
|
+
catch (cause) {
|
|
288
|
+
if (cause instanceof SyntaxError || cause instanceof z.ZodError)
|
|
289
|
+
throw new InstallationOperationError("stored_operation_invalid");
|
|
290
|
+
throw cause;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const jobKey = (requestId) => `installation:${createHash("sha256").update(requestId).digest("hex")}`;
|
|
294
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
295
|
+
const version = (record) => record?.authority.authorityVersion ?? 0;
|
|
296
|
+
/** Whether a plan only reuses, or narrows, the approval its baseline holds. */
|
|
297
|
+
function reusesApproval(baseline, next) {
|
|
298
|
+
const grants = baseline?.authority.approvedGrants ?? [];
|
|
299
|
+
const methods = baseline?.approvedAnonymousMethods ?? [];
|
|
300
|
+
return (next.authority.approvedGrants.every((grant) => grants.some((held) => held.capability === grant.capability &&
|
|
301
|
+
same(held.resources, grant.resources) &&
|
|
302
|
+
grant.operations.every((operation) => held.operations.includes(operation)))) &&
|
|
303
|
+
(next.authority.approval === null ||
|
|
304
|
+
same(next.authority.approval, baseline?.authority.approval ?? null)) &&
|
|
305
|
+
next.approvedAnonymousMethods.every((method) => methods.some((held) => held.name === method.name && held.digest === method.digest)) &&
|
|
306
|
+
(next.anonymousApproval === null ||
|
|
307
|
+
same(next.anonymousApproval, baseline?.anonymousApproval ?? null)));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* One registry-owned command journal joined to maintained Watchdog transactions.
|
|
311
|
+
* The executor runs outside this owner. No release bytes or guest work enter it.
|
|
312
|
+
*/
|
|
313
|
+
export async function createInstallationOperations(ports) {
|
|
314
|
+
const { owner } = ports;
|
|
315
|
+
owner.sql.exec(`CREATE TABLE IF NOT EXISTS installation_commands (
|
|
316
|
+
request_id TEXT PRIMARY KEY, installation_id TEXT NOT NULL,
|
|
317
|
+
command TEXT NOT NULL, provisional_outcome TEXT,
|
|
318
|
+
settled_at INTEGER, retention_due_at INTEGER
|
|
319
|
+
) STRICT`);
|
|
320
|
+
owner.sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS installation_commands_latest
|
|
321
|
+
ON installation_commands(installation_id)
|
|
322
|
+
WHERE settled_at IS NOT NULL AND retention_due_at IS NULL`);
|
|
323
|
+
owner.sql.exec(`CREATE INDEX IF NOT EXISTS installation_commands_retention
|
|
324
|
+
ON installation_commands(retention_due_at)
|
|
325
|
+
WHERE retention_due_at IS NOT NULL`);
|
|
326
|
+
const load = (requestId) => {
|
|
327
|
+
const row = owner.sql
|
|
328
|
+
.exec("SELECT command,provisional_outcome FROM installation_commands WHERE request_id=?", requestId)
|
|
329
|
+
.toArray()[0];
|
|
330
|
+
if (row === undefined)
|
|
331
|
+
return null;
|
|
332
|
+
const stored = storedRow.parse(row);
|
|
333
|
+
// A stale provisional result must refuse owner reads too, not remain
|
|
334
|
+
// invisibly pending until an alarm tries to settle it.
|
|
335
|
+
if (stored.provisional_outcome !== null)
|
|
336
|
+
readStored(installationOutcomeSchema, stored.provisional_outcome);
|
|
337
|
+
return readStored(storedCommandSchema, stored.command);
|
|
338
|
+
};
|
|
339
|
+
const save = (command) => {
|
|
340
|
+
const stored = storedCommandSchema.parse(command);
|
|
341
|
+
if ("kind" in stored)
|
|
342
|
+
owner.sql.exec(`UPDATE installation_commands
|
|
343
|
+
SET retention_due_at=settled_at+?
|
|
344
|
+
WHERE installation_id=? AND settled_at IS NOT NULL
|
|
345
|
+
AND retention_due_at IS NULL AND request_id<>?`, settledRetentionMs, stored.operation.installationId, stored.operation.requestId);
|
|
346
|
+
owner.sql.exec(`INSERT INTO installation_commands
|
|
347
|
+
(request_id,installation_id,command,settled_at,retention_due_at)
|
|
348
|
+
VALUES (?,?,?,?,NULL)
|
|
349
|
+
ON CONFLICT(request_id) DO UPDATE SET
|
|
350
|
+
command=excluded.command,settled_at=excluded.settled_at,
|
|
351
|
+
retention_due_at=excluded.retention_due_at`, stored.operation.requestId, stored.operation.installationId, JSON.stringify(stored), "kind" in stored ? stored.settledAt : null);
|
|
352
|
+
};
|
|
353
|
+
const pruneSettled = () => {
|
|
354
|
+
owner.transactionSync(() => {
|
|
355
|
+
const candidates = owner.sql
|
|
356
|
+
.exec(`SELECT request_id,installation_id,settled_at,retention_due_at,provisional_outcome
|
|
357
|
+
FROM installation_commands WHERE retention_due_at<=?
|
|
358
|
+
ORDER BY retention_due_at,rowid LIMIT ?`, Date.now(), retentionBatchSize)
|
|
359
|
+
.toArray();
|
|
360
|
+
for (const candidate of candidates) {
|
|
361
|
+
const parsed = retentionCandidateRow.safeParse(candidate);
|
|
362
|
+
if (!parsed.success)
|
|
363
|
+
throw new InstallationOperationError("stored_operation_invalid");
|
|
364
|
+
const row = parsed.data;
|
|
365
|
+
const command = load(row.request_id);
|
|
366
|
+
if (command === null ||
|
|
367
|
+
!("kind" in command) ||
|
|
368
|
+
command.operation.requestId !== row.request_id ||
|
|
369
|
+
command.operation.installationId !== row.installation_id ||
|
|
370
|
+
command.settledAt !== row.settled_at ||
|
|
371
|
+
row.retention_due_at !== command.settledAt + settledRetentionMs ||
|
|
372
|
+
row.provisional_outcome !== null)
|
|
373
|
+
throw new InstallationOperationError("stored_operation_invalid");
|
|
374
|
+
owner.sql.exec("DELETE FROM installation_commands WHERE request_id=? AND retention_due_at=?", row.request_id, row.retention_due_at);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
const nextRetentionWake = () => retentionWakeRow.parse(owner.sql
|
|
379
|
+
.exec(`SELECT MIN(retention_due_at) AS due FROM installation_commands
|
|
380
|
+
WHERE retention_due_at IS NOT NULL`)
|
|
381
|
+
.toArray()[0]).due;
|
|
382
|
+
const operationOf = (command) => {
|
|
383
|
+
const operation = command.operation;
|
|
384
|
+
if (operation.state !== "succeeded")
|
|
385
|
+
return operation;
|
|
386
|
+
const current = ports.generation() === command.generation &&
|
|
387
|
+
command.generation === operation.reference.generation
|
|
388
|
+
? ports.get(operation.installationId)
|
|
389
|
+
: null;
|
|
390
|
+
return {
|
|
391
|
+
...operation,
|
|
392
|
+
installation: matchesReference(operation, current) ? current : null,
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
// Only the running executor proposes evidence. Watchdog's private claim
|
|
396
|
+
// decides whether it may become terminal, in the settlement transaction.
|
|
397
|
+
const provisional = (requestId) => {
|
|
398
|
+
const row = owner.sql
|
|
399
|
+
.exec("SELECT provisional_outcome FROM installation_commands WHERE request_id=?", requestId)
|
|
400
|
+
.toArray()[0];
|
|
401
|
+
const encoded = row === undefined
|
|
402
|
+
? null
|
|
403
|
+
: z
|
|
404
|
+
.strictObject({ provisional_outcome: z.string().nullable() })
|
|
405
|
+
.parse(row).provisional_outcome;
|
|
406
|
+
return encoded === null
|
|
407
|
+
? null
|
|
408
|
+
: readStored(installationOutcomeSchema, encoded);
|
|
409
|
+
};
|
|
410
|
+
const watchdog = await Watchdog.make({
|
|
411
|
+
owner,
|
|
412
|
+
isAlive: () => Promise.resolve(false),
|
|
413
|
+
wake: { recompute: () => ports.scheduleWake(Date.now()) },
|
|
414
|
+
project: ({ settled }) => {
|
|
415
|
+
if (settled === undefined)
|
|
416
|
+
return;
|
|
417
|
+
const payload = installationRequestIdSchema.parse(settled.payload);
|
|
418
|
+
const command = load(payload);
|
|
419
|
+
if (command === null || !("admission" in command))
|
|
420
|
+
return;
|
|
421
|
+
const proposed = provisional(payload);
|
|
422
|
+
const definitive = (proposed?.state === "succeeded" &&
|
|
423
|
+
settled.outcome === "completed" &&
|
|
424
|
+
ports.generation() === command.generation &&
|
|
425
|
+
same(ports.get(command.installationId), proposed.installation)) ||
|
|
426
|
+
(proposed?.state === "refused" && settled.outcome === "failed");
|
|
427
|
+
const result = definitive
|
|
428
|
+
? proposed
|
|
429
|
+
: {
|
|
430
|
+
state: "unconfirmed",
|
|
431
|
+
outcome: settled.outcome === "interrupted" ||
|
|
432
|
+
settled.outcome === "cancelled"
|
|
433
|
+
? settled.outcome
|
|
434
|
+
: "outcome_unknown",
|
|
435
|
+
};
|
|
436
|
+
const operation = terminalOperationSchema.parse(result.state === "succeeded"
|
|
437
|
+
? {
|
|
438
|
+
...command.operation,
|
|
439
|
+
state: result.state,
|
|
440
|
+
reference: {
|
|
441
|
+
generation: command.generation,
|
|
442
|
+
instanceId: result.installation.instanceId,
|
|
443
|
+
authorityVersion: result.installation.authority.authorityVersion,
|
|
444
|
+
status: result.installation.status,
|
|
445
|
+
},
|
|
446
|
+
creation: result.creation,
|
|
447
|
+
uiCheck: result.uiCheck,
|
|
448
|
+
}
|
|
449
|
+
: { ...command.operation, ...result });
|
|
450
|
+
save({
|
|
451
|
+
kind: "settled",
|
|
452
|
+
inputDigest: inputDigest(command.admission),
|
|
453
|
+
scope: command.scope,
|
|
454
|
+
generation: command.generation,
|
|
455
|
+
acceptedAt: command.acceptedAt,
|
|
456
|
+
settledAt: Date.now(),
|
|
457
|
+
operation,
|
|
458
|
+
});
|
|
459
|
+
owner.sql.exec("UPDATE installation_commands SET provisional_outcome=NULL WHERE request_id=?", payload);
|
|
460
|
+
},
|
|
461
|
+
execute: async (job) => {
|
|
462
|
+
const command = load(installationRequestIdSchema.parse(job.payload));
|
|
463
|
+
if (command === null || !("admission" in command))
|
|
464
|
+
return { kind: "outcome_unknown" };
|
|
465
|
+
// Persist the wake handoff before awaiting work in another execution context.
|
|
466
|
+
await ports.scheduleWake(Date.now());
|
|
467
|
+
const result = installationOutcomeSchema.parse(await ports.execute(command));
|
|
468
|
+
const latest = load(command.admission.requestId);
|
|
469
|
+
if (latest === null || !("admission" in latest))
|
|
470
|
+
throw new InstallationOperationError("stale_authority");
|
|
471
|
+
if (result.state === "succeeded" &&
|
|
472
|
+
(latest.plan === null ||
|
|
473
|
+
!same(result.installation, latest.plan.installation) ||
|
|
474
|
+
result.creation !== latest.creation))
|
|
475
|
+
throw new InstallationOperationError("stale_authority");
|
|
476
|
+
owner.transactionSync(() => {
|
|
477
|
+
owner.sql.exec("UPDATE installation_commands SET provisional_outcome=? WHERE request_id=?", JSON.stringify(result), command.admission.requestId);
|
|
478
|
+
});
|
|
479
|
+
return result.state === "succeeded"
|
|
480
|
+
? { kind: "completed" }
|
|
481
|
+
: result.state === "refused"
|
|
482
|
+
? { kind: "failed", reason: result.code }
|
|
483
|
+
: result.outcome === "cancelled"
|
|
484
|
+
? { kind: "cancelled" }
|
|
485
|
+
: { kind: "outcome_unknown" };
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
const requireCommand = (requestId, scope) => {
|
|
489
|
+
const command = load(requestId);
|
|
490
|
+
if (command === null || !same(command.scope, scope))
|
|
491
|
+
throw new InstallationOperationError("unavailable");
|
|
492
|
+
if (!("admission" in command))
|
|
493
|
+
throw new InstallationOperationError("stale_authority");
|
|
494
|
+
return command;
|
|
495
|
+
};
|
|
496
|
+
const dispatch = async (raw) => {
|
|
497
|
+
const request = installationOperationRequestSchema.parse(raw);
|
|
498
|
+
const scope = tenantScope(request.scope.tenantId, request.scope.origin);
|
|
499
|
+
if (scope.durableName("registry") !== ports.registryName)
|
|
500
|
+
throw new InstallationOperationError("unavailable");
|
|
501
|
+
if (request.action === "admit") {
|
|
502
|
+
if (request.input.actor.kind !== "member")
|
|
503
|
+
throw new InstallationOperationError("unavailable");
|
|
504
|
+
const scheduled = await watchdog.read(jobKey(request.input.requestId));
|
|
505
|
+
// An orphan wake is harmless; an accepted row with no wake is not.
|
|
506
|
+
// No external await separates this persisted wake from atomic admission.
|
|
507
|
+
await ports.scheduleWake(Date.now());
|
|
508
|
+
return owner.transactionSync(() => {
|
|
509
|
+
const prior = load(request.input.requestId);
|
|
510
|
+
if (prior !== null) {
|
|
511
|
+
if (("admission" in prior
|
|
512
|
+
? !same(prior.admission, request.input)
|
|
513
|
+
: prior.inputDigest !== inputDigest(request.input)) ||
|
|
514
|
+
!same(prior.scope, request.scope))
|
|
515
|
+
throw new InstallationOperationError("request_conflict");
|
|
516
|
+
return { kind: "operation", operation: operationOf(prior) };
|
|
517
|
+
}
|
|
518
|
+
// Expiring App history does not erase Watchdog's job identity.
|
|
519
|
+
// A concurrent admission still replays through the journal above.
|
|
520
|
+
if (scheduled.status === "found")
|
|
521
|
+
throw new InstallationOperationError("unavailable");
|
|
522
|
+
const requested = ports.get(request.input.installationId);
|
|
523
|
+
let installationId = request.input.installationId;
|
|
524
|
+
let creation = requested === null ? "fresh" : "replacement";
|
|
525
|
+
if (requested?.status === "deleted" &&
|
|
526
|
+
request.input.intent === "install-or-remint") {
|
|
527
|
+
const suffix = installationCommandIds.installation
|
|
528
|
+
.generate()
|
|
529
|
+
.slice("installation_".length);
|
|
530
|
+
installationId = `${installationId.slice(0, 41).replace(/-+$/u, "")}-${suffix}`;
|
|
531
|
+
creation = "reminted";
|
|
532
|
+
}
|
|
533
|
+
const baseline = ports.get(installationId);
|
|
534
|
+
if ((creation !== "replacement" ||
|
|
535
|
+
request.input.intent === "create-only") &&
|
|
536
|
+
baseline !== null)
|
|
537
|
+
throw new InstallationOperationError("stale_authority");
|
|
538
|
+
const operation = pendingOperationSchema.parse({
|
|
539
|
+
requestId: request.input.requestId,
|
|
540
|
+
requestedInstallationId: request.input.installationId,
|
|
541
|
+
installationId,
|
|
542
|
+
release: request.input.release,
|
|
543
|
+
state: "pending",
|
|
544
|
+
});
|
|
545
|
+
save({
|
|
546
|
+
admission: request.input,
|
|
547
|
+
scope: request.scope,
|
|
548
|
+
installationId,
|
|
549
|
+
instanceId: baseline?.instanceId ?? instanceIds.instance.generate(),
|
|
550
|
+
baseline,
|
|
551
|
+
generation: ports.generation(),
|
|
552
|
+
acceptedAt: Date.now(),
|
|
553
|
+
creation,
|
|
554
|
+
plan: null,
|
|
555
|
+
operation,
|
|
556
|
+
});
|
|
557
|
+
watchdog.transactional.enqueueAcceptedJob({
|
|
558
|
+
jobId: jobKey(request.input.requestId),
|
|
559
|
+
queue: installationId,
|
|
560
|
+
lane: "installation",
|
|
561
|
+
priority: 0,
|
|
562
|
+
payload: request.input.requestId,
|
|
563
|
+
state: "queued",
|
|
564
|
+
});
|
|
565
|
+
return { kind: "operation", operation };
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
if (request.action === "read" || request.action === "load") {
|
|
569
|
+
const command = load(request.requestId);
|
|
570
|
+
const admitted = command !== null && same(command.scope, request.scope) ? command : null;
|
|
571
|
+
return request.action === "load"
|
|
572
|
+
? {
|
|
573
|
+
kind: "command",
|
|
574
|
+
command: admitted !== null && "admission" in admitted ? admitted : null,
|
|
575
|
+
}
|
|
576
|
+
: {
|
|
577
|
+
kind: "operation",
|
|
578
|
+
operation: admitted !== null &&
|
|
579
|
+
(admitted.operation.installationId === request.installationId ||
|
|
580
|
+
admitted.operation.requestedInstallationId ===
|
|
581
|
+
request.installationId)
|
|
582
|
+
? operationOf(admitted)
|
|
583
|
+
: null,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const command = requireCommand(request.requestId, request.scope);
|
|
587
|
+
if (request.action === "plan") {
|
|
588
|
+
const next = request.installation;
|
|
589
|
+
if (next.installationId !== command.installationId ||
|
|
590
|
+
next.instanceId !== command.instanceId ||
|
|
591
|
+
next.authority.authorityVersion !== version(command.baseline) + 1 ||
|
|
592
|
+
!same(next.release, command.admission.release))
|
|
593
|
+
throw new InstallationOperationError("stale_authority");
|
|
594
|
+
if (command.admission.origin === "workflow" &&
|
|
595
|
+
!reusesApproval(command.baseline, next))
|
|
596
|
+
throw new InstallationOperationError("needs_consent");
|
|
597
|
+
if (command.plan !== null &&
|
|
598
|
+
(!same(command.plan.installation, next) ||
|
|
599
|
+
command.plan.hasBackend !== request.hasBackend))
|
|
600
|
+
throw new InstallationOperationError("stale_authority");
|
|
601
|
+
const planned = {
|
|
602
|
+
...command,
|
|
603
|
+
plan: command.plan ?? {
|
|
604
|
+
installation: next,
|
|
605
|
+
hasBackend: request.hasBackend,
|
|
606
|
+
prepared: false,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
owner.transactionSync(() => {
|
|
610
|
+
save(planned);
|
|
611
|
+
});
|
|
612
|
+
return { kind: "command", command: planned };
|
|
613
|
+
}
|
|
614
|
+
if (command.plan === null)
|
|
615
|
+
throw new InstallationOperationError("unavailable");
|
|
616
|
+
if (command.plan.prepared &&
|
|
617
|
+
JSON.stringify(command.plan.uiCheck) !== JSON.stringify(request.uiCheck))
|
|
618
|
+
throw new InstallationOperationError("stale_authority");
|
|
619
|
+
const prepared = {
|
|
620
|
+
...command,
|
|
621
|
+
plan: { ...command.plan, prepared: true, uiCheck: request.uiCheck },
|
|
622
|
+
};
|
|
623
|
+
owner.transactionSync(() => {
|
|
624
|
+
save(prepared);
|
|
625
|
+
});
|
|
626
|
+
if (ports.generation() !== command.generation)
|
|
627
|
+
return { kind: "commit", commit: { ok: false, code: "stale_authority" } };
|
|
628
|
+
const current = ports.get(command.installationId);
|
|
629
|
+
// Exact replay, not another reducer invocation/version increment.
|
|
630
|
+
const commit = same(current, command.plan.installation)
|
|
631
|
+
? { ok: true }
|
|
632
|
+
: await ports.compareAndPut(command.plan.installation, version(command.baseline), command.generation, command.scope);
|
|
633
|
+
return { kind: "commit", commit };
|
|
634
|
+
};
|
|
635
|
+
return {
|
|
636
|
+
async dispatch(raw) {
|
|
637
|
+
try {
|
|
638
|
+
return await dispatch(raw);
|
|
639
|
+
}
|
|
640
|
+
catch (cause) {
|
|
641
|
+
if (cause instanceof InstallationOperationError)
|
|
642
|
+
return { kind: "refused", code: cause.code };
|
|
643
|
+
throw cause;
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
async tick() {
|
|
647
|
+
// A failed batch rolls back and uses the host's alarm failure handling,
|
|
648
|
+
// rather than repeatedly arming its already-expired deadline.
|
|
649
|
+
pruneSettled();
|
|
650
|
+
try {
|
|
651
|
+
return await watchdog.tick();
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
if (await watchdog.hasPendingWork())
|
|
655
|
+
await ports.scheduleWake(Date.now());
|
|
656
|
+
else {
|
|
657
|
+
const due = nextRetentionWake();
|
|
658
|
+
if (due !== null)
|
|
659
|
+
await ports.scheduleWake(due);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { defineIdFamily } from "@fungi.computer/cairn/id";
|
|
3
|
+
const installationStoreIds = defineIdFamily({
|
|
4
|
+
family: "installation-store",
|
|
5
|
+
version: 1,
|
|
6
|
+
kinds: { generation: { prefix: "store" } },
|
|
7
|
+
});
|
|
8
|
+
const generationRow = Schema.decodeUnknownSync(Schema.Struct({ generation: Schema.NonEmptyString }));
|
|
9
|
+
/** Own one persisted store incarnation, independent of installation row revisions. */
|
|
10
|
+
export function createInstallationStoreGeneration(sql) {
|
|
11
|
+
sql.exec("CREATE TABLE IF NOT EXISTS installation_store (id INTEGER PRIMARY KEY CHECK (id=1), generation TEXT NOT NULL) STRICT");
|
|
12
|
+
sql.exec("INSERT OR IGNORE INTO installation_store (id,generation) VALUES(1,?)", installationStoreIds.generation.generate());
|
|
13
|
+
return () => generationRow(sql
|
|
14
|
+
.exec("SELECT generation FROM installation_store WHERE id=1")
|
|
15
|
+
.toArray()[0]).generation;
|
|
16
|
+
}
|